Add source code, design assets, and CAD samples

This commit is contained in:
2026-05-07 20:30:34 +09:00
parent 720858c7ae
commit 184185c635
49 changed files with 3407636 additions and 0 deletions

153
.gitignore vendored Normal file
View File

@@ -0,0 +1,153 @@
# ========================================================================
# S-CANVAS .gitignore
# ========================================================================
# ───────────────────────────────────────────────────────────────────────
# 1. 자격 증명 / 시크릿 (절대 커밋 금지)
# ───────────────────────────────────────────────────────────────────────
gcp-key.json
*-credentials.json
*-service-account.json
.env
.env.*
*.pem
*.key
# ───────────────────────────────────────────────────────────────────────
# 2. Python
# ───────────────────────────────────────────────────────────────────────
__pycache__/
*.py[cod]
*$py.class
*.so
*.egg
*.egg-info/
.Python
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
# 가상환경
venv/
env/
.venv/
ENV/
.python-version
# ───────────────────────────────────────────────────────────────────────
# 3. PyInstaller 빌드 산출물
# (scanvas_maker.spec, build.bat 은 추적 — 빌드 재현용)
# ───────────────────────────────────────────────────────────────────────
build/
dist/
*.manifest
# scanvas_maker.spec 외 자동 생성 spec 만 차단하려면 아래를 활성화:
# *.spec
# !scanvas_maker.spec
# ───────────────────────────────────────────────────────────────────────
# 4. 런타임 데이터 (DB · 로그 · 캐시)
# ───────────────────────────────────────────────────────────────────────
# SQLite Job 이력
*.db
*.db-journal
*.sqlite
*.sqlite3
scanvas_jobs.db
egview_jobs.db
# 로그
*.log
scanvas_harness.log
scanvas_diagnostic.log
egview_harness.log
egview_diagnostic.log
Build_log.txt
# DEM / 타일 / VLM 캐시
cache/
!cache/.gitkeep
# ───────────────────────────────────────────────────────────────────────
# 5. 런타임 생성 이미지 (Step 2~4 산출물)
# 매 실행 시 재생성 — 추적 불필요
# ───────────────────────────────────────────────────────────────────────
capture_for_ai.png
capture_textured.png
depth_map.png
lineart_map.png
guide_composite.png
rendered_birdseye.png
satellite_temp.png
error.png
error*.png
# ───────────────────────────────────────────────────────────────────────
# 6. 구버전 / 미사용 자료 (EG-VIEW 시절 잔존물)
# ───────────────────────────────────────────────────────────────────────
_unused/
SCREENSHOT_LOG/
scratch/
# ───────────────────────────────────────────────────────────────────────
# 7. 대용량 / 선택적 자산
# ───────────────────────────────────────────────────────────────────────
# 로컬 GeoTIFF DEM (NGII 5m 등) — 수백 MB
cache/dem/local.tif
*.tif
*.tiff
!Design/*.tif
!Design/*.tiff
# 대용량 DXF 샘플은 SAMPLE_CAD/ 하위만 추적, 그 외 *.dxf 는 차단하고 싶다면:
# *.dxf
# !SAMPLE_CAD/**/*.dxf
# ───────────────────────────────────────────────────────────────────────
# 8. IDE / 에디터 / AI 코딩 도구
# ───────────────────────────────────────────────────────────────────────
.vscode/
.idea/
*.swp
*.swo
*~
.cursorrules
.claude/
.aider*
.cursor/
# Jupyter
.ipynb_checkpoints/
*.ipynb_checkpoints
# ───────────────────────────────────────────────────────────────────────
# 9. OS 메타파일
# ───────────────────────────────────────────────────────────────────────
# Windows
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/
# macOS
.DS_Store
.AppleDouble
.LSOverride
._*
# Linux
*~
.fuse_hidden*
.Trash-*
# ───────────────────────────────────────────────────────────────────────
# 10. 백업 / 임시
# ───────────────────────────────────────────────────────────────────────
*.bak
*.backup
*.orig
*.tmp
*.temp
~$*

1867
CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

BIN
Design/Logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

Binary file not shown.

BIN
Design/SAMAN_CI.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
Design/homepage_sample.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

BIN
Design/logo_V2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 MiB

BIN
Design/logo_intro.mp4 Normal file

Binary file not shown.

BIN
Design/page_sample.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 KiB

737
S-CANVAS_brief.md Normal file
View 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", "마인드맵", "발표 슬라이드 초안" 자동 생성을 권장합니다.*

File diff suppressed because it is too large Load Diff

1446740
SAMPLE_CAD/수문_1.dxf Normal file

File diff suppressed because it is too large Load Diff

356110
SAMPLE_CAD/수문_2.dxf Normal file

File diff suppressed because it is too large Load Diff

67
_build_icon.py Normal file
View 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())

61
build.bat Normal file
View 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

877
dem_extender.py Normal file
View File

@@ -0,0 +1,877 @@
"""실제 지형 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, field
from pathlib import Path
from typing import Callable, Optional
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) -> Optional[tuple[np.ndarray, tuple[float, float, float, float], str]]:
"""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))))
for t in np.linspace(0.0, 1.0, k, endpoint=False):
out.append(p0 + seg * t)
return np.asarray(out, dtype=np.float64)
def _generate_ring_points_hull(outer_xyxy: tuple[float, float, float, float],
inner_hull_xy: np.ndarray,
grid_step: float,
precomputed_boundary: Optional[np.ndarray] = None,
) -> 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용.
"""
from matplotlib.path import Path as MplPath
ox0, oy0, ox1, oy1 = outer_xyxy
xs = np.arange(ox0, ox1 + grid_step * 0.5, grid_step)
ys = np.arange(oy0, oy1 + grid_step * 0.5, grid_step)
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)
if parts:
boundary_pts = np.unique(np.round(np.vstack(parts), 3), axis=0)
else:
boundary_pts = 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: Optional[np.ndarray] = None,
grid_step_m: Optional[float] = None,
feather_m: float = 80.0,
zoom: int = 13,
cache_dir: str | Path = "cache/dem",
source: str = "auto",
use_hull_boundary: bool = True,
datum_offset_override: Optional[float] = None,
elev_grid_override: Optional[np.ndarray] = None,
grid_bounds_override: Optional[tuple[float, float, float, float]] = None,
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
hull_z_abs = None
n_hull_boundary = 0
boundary_mode = "bbox"
if use_hull_boundary and tin_xyz_zerobased is not None and len(tin_xyz_zerobased) >= 3:
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_tree_corner = cKDTree(tin_abs[:, :2])
_, _ci = _tin_tree_corner.query(hull_xy_abs, k=1)
hull_z_abs = tin_abs[_ci, 2]
# **TIN의 실제 bbox 변 정점**만 선별해 DEM 링 inner 경계로 사용 →
# 두 메시가 동일 XY의 공유 정점을 가지므로 T-vertex/fin 원천 제거.
bbox_tol_tin = max(tbx1 - tbx0, tby1 - tby0) * 1e-4 + 1e-3
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; hull_z_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}")
# 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)

553
detail_parser.py Normal file
View File

@@ -0,0 +1,553 @@
"""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
import math
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
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: Optional[float] = 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

584
dxf_geometry.py Normal file
View File

@@ -0,0 +1,584 @@
"""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
from typing import Optional
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) -> Optional[Shape]:
"""면적이 가장 큰 closed shape 반환."""
if not self.closed_shapes:
return None
return max(self.closed_shapes, key=lambda s: s.area)
def longest_polyline(self) -> Optional[Shape]:
"""가장 긴 폴리라인 반환."""
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
for pat in _EXCLUDE_LAYER_PATTERNS:
if re.search(pat, layer_name, re.IGNORECASE):
return True
return False
# ---------------------------------------------------------------------------
# 단위 자동 감지
# ---------------------------------------------------------------------------
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이 없거나 모호할 때)
try:
insunits = int(doc.header.get("$INSUNITS", 0))
# 0=unitless, 1=inch, 2=feet, 4=mm, 5=cm, 6=m
if insunits == 4:
return 0.001, "mm"
elif insunits == 6:
return 1.0, "m"
elif insunits == 5:
return 0.01, "cm"
# inch/feet는 한국 토목도면에서 거의 없음 → 무시하고 bbox로 판단
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: Optional[str] = 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, depth: int = 0):
"""단일 엔티티 처리. INSERT면 explode_blocks에 따라 재귀 확장."""
etype = entity.dxftype()
# 블록 내부 엔티티의 layer가 "0"이면 INSERT의 레이어를 상속
raw_layer = getattr(entity.dxf, "layer", "")
if inherited_layer and raw_layer in ("", "0"):
layer = inherited_layer
else:
layer = raw_layer
# 레이어 필터
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) -> Optional[Shape]:
"""개별 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 여수로 수문 설치도(12).dxf"),
str(base / "12995740-M40-002 여수로 수문 설치도(22).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(f" 레이어별:")
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)")

237
filename_classifier.py Normal file
View File

@@ -0,0 +1,237 @@
"""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
from typing import Optional
# ---------------------------------------------------------------------------
# 구조물 유형별 키워드 패턴
# ---------------------------------------------------------------------------
# 우선순위가 높은 것을 먼저 (더 구체적인 패턴 → 일반적인 패턴)
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) -> Optional[str]:
"""파일명에서 구조물 유형을 추정.
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]) -> Optional[str]:
"""여러 파일의 이름을 종합해서 가장 가능성 높은 유형 추정."""
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[Optional[str], 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 = []
for pat in patterns:
if re.search(pat, cleaned, re.IGNORECASE):
matched_patterns.append(pat)
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 여수로 수문 설치도(12).dxf",
"12995740-M40-002 여수로 수문 설치도(22).dxf",
"12996710-M40-001 신설 취수탑 설비 설치도(12).dxf",
"12996710-M40-002 신설 취수탑 설비 설치도(22).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}")

743
gate_3d_builder.py Normal file
View File

@@ -0,0 +1,743 @@
"""여수로 수문 구조물 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
from dataclasses import dataclass
from typing import Optional
import numpy as np
import pyvista as pv
from gate_parser import GateParams
# ---------------------------------------------------------------------------
# 재질 색상
# ---------------------------------------------------------------------------
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)) + [(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) -> Optional[pv.PolyData]:
"""프리즘 메쉬 생성 (두 개의 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)
for i in range(1, n - 1):
faces.append([3, 0, i, i + 1])
# 뒷면 (X=span)
for i in range(1, n - 1):
faces.append([3, n, n + i + 1, n + i])
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:
try:
mesh = mesh.merge(nose)
except Exception:
pass
self.meshes.append((mesh, COLORS["pier"], 1.0))
def _extrude_polygon_xy(self, poly_xy: list, z_bot: float, z_top: float) -> Optional[pv.PolyData]:
"""임의 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
if not (pier_length * 0.05 <= h <= pier_length * 1.2):
return False
return True
def _make_pier_nose(self, cx: float, width: float,
y_front: float, z_bot: float, z_top: float) -> Optional[pv.PolyData]:
"""교각 상류측 삼각형 물가르기 노즈."""
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
if abs(trunnion_el_user - mid_el) > 0.5:
trunnion_el = mid_el
else:
trunnion_el = trunnion_el_user
dz_half = abs(trunnion_el - sill_el)
horizontal = math.sqrt(max(0.01, radius ** 2 - dz_half ** 2))
body_len = p.pier_length if p.pier_length and p.pier_length > 0 else 25.0
crest_y_candidate: Optional[float] = None
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) -> Optional[pv.PolyData]:
"""래디얼 게이트의 스킨플레이트 (원통면 일부).
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
for s in [-half_w, half_w]:
pts.append([cx + s, y, z])
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) -> Optional[pv.PolyData]:
"""게이트 암: 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에 남김
try:
p.raw_text_annotations.append((
f"[builder] bridge source={source} bbox=({x0:.2f},{y0:.2f},{x1:.2f},{y1:.2f})",
0.0, 0.0
))
except Exception:
pass
deck = self._make_box(x0, x1, y0, y1, deck_bot, deck_top)
self.meshes.append((deck, COLORS["bridge_deck"], 1.0))
# 양쪽 난간
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 여수로 수문 설치도(12).dxf"
f2 = base / "12995740-M40-002 여수로 수문 설치도(22).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}")

1221
gate_parser.py Normal file

File diff suppressed because it is too large Load Diff

285
gemini_renderer.py Normal file
View File

@@ -0,0 +1,285 @@
"""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
# 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: 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: app.log(
f" [Vertex] 모델 {_m} @ {_l} 성공"))
break
except Exception as exc:
_m, _e = model_name, str(exc)[:120]
app.after(0, lambda: app.log(f" [Vertex] {_m}: {_e}"))
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: app.log(f" 모델 {_m} 성공"))
break
except Exception as exc:
_m, _e = model_name, str(exc)[:80]
app.after(0, lambda: app.log(f" {_m}: {_e}"))
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:
if part.inline_data.mime_type.startswith("image/"):
rendered = Image.open(io.BytesIO(part.inline_data.data))
break
if rendered:
_m = model_name
app.after(0, lambda: app.log(f" 모델 {_m} 성공"))
break
except Exception as exc:
_m, _e = model_name, str(exc)[:80]
app.after(0, lambda: 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
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: app.log(f" 품질검증 오류: {e}"))
if app.job_logger and db and job_id:
try:
app.job_logger.complete_job(db, job_id, output_path, quality_score, latency_ms)
except Exception:
pass
app.after(0, lambda: app.log(
f" Gemini 렌더링 완료! → {output_path} ({rendered.size}) "
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:
try:
app.job_logger.fail_job(db, job_id, str(e))
except Exception:
pass
err_msg = str(e)[:300]
app.after(0, lambda: app.log(f" Gemini 오류: {err_msg}"))
app.after(0, lambda: app.set_status("렌더링 실패", "#E74C3C"))
app.after(0, lambda: messagebox.showerror("Gemini 오류", f"API 호출 오류:\n{err_msg}"))
finally:
if db:
try:
db.close()
except Exception:
pass

1094
geo_referencing.py Normal file

File diff suppressed because it is too large Load Diff

0
harness/__init__.py Normal file
View File

137
harness/logger.py Normal file
View 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()

View 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("prompt_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

View 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
harness/seed_manager.py Normal file
View 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})"

463
intake_tower_3d_builder.py Normal file
View File

@@ -0,0 +1,463 @@
"""취수탑 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 math
from typing import Optional
import numpy as np
import pyvista as pv
from intake_tower_parser import IntakeTowerParams, GatePosition
# 색상 팔레트
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
hw = p.body_width / 2
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
# 크레인 주황색 박스 (레일 중간에 매달린 형태)
hw = p.body_width / 2
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
hd = p.body_depth / 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 # 디딤판 깊이
total_run = n_steps * step_d
# 계단 위치: 본체 좌측 외부
x_start = -hw - 1.0
x_end = x_start - total_run # 서쪽 방향
if p.stairs_side == "right":
x_start = hw + 1.0
x_end = x_start + total_run
# 계단을 한 덩어리 경사판으로 표현 (간략화)
# 또는 각 단을 박스로
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
from pathlib import Path
paths = [
"SAMPLE_CAD/12996710-M40-001 신설 취수탑 설비 설치도(12).dxf",
"SAMPLE_CAD/12996710-M40-002 신설 취수탑 설비 설치도(22).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}")

361
intake_tower_parser.py Normal file
View File

@@ -0,0 +1,361 @@
"""취수탑 (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
import math
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import ezdxf
import numpy as np
from view_detector import detect_view_regions, ViewRegion
from dxf_geometry import extract_structural_geometry
# ---------------------------------------------------------------------------
# 데이터 클래스
# ---------------------------------------------------------------------------
@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, max(els))
params.body_bottom_el = min(params.body_bottom_el, min(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) -> Optional[ViewRegion]:
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 정렬 여부 확인
xs = [c[0] for c in main_group]
ys = [c[1] for c in main_group]
x_var = max(xs) - min(xs)
y_var = max(ys) - min(ys)
# X 변화가 크면 → 수문이 좌우 배치 (평면도), Y 변화가 크면 → 상하 배치 (정면도, 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) -> Optional[dict]:
"""상단 긴 수평선 검출 → 호이스트 레일."""
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:
if params.hoist_rail_el > params.body_top_el or params.hoist_rail_el < params.body_bottom_el:
params.hoist_rail_el = params.body_top_el - 2.0
# 편의 함수
def parse_intake_tower(dxf_paths: list[str]) -> IntakeTowerParams:
return IntakeTowerParser().parse(dxf_paths)
if __name__ == "__main__":
import sys
from pathlib import Path
paths = sys.argv[1:] if len(sys.argv) > 1 else [
"SAMPLE_CAD/12996710-M40-001 신설 취수탑 설비 설치도(12).dxf",
"SAMPLE_CAD/12996710-M40-002 신설 취수탑 설비 설치도(22).dxf",
]
params = parse_intake_tower(paths)
print(params.summary())
print()
print(f"상세 수문 정보:")
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")

172
optional_detector.py Normal file
View File

@@ -0,0 +1,172 @@
"""구조물 부속 컴포넌트(공도교/개폐장치/사다리/덮개/에이프런 등) 존재 여부를
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 typing import Iterable, Optional
# 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:
present = True
elif spec.allow_text_only and text_count > 0:
present = True
elif spec.preserve_default_on_no_signal and geom_count == 0 and text_count == 0:
present = spec.default
else:
# 신호 부재 → default를 False로 낮춤
present = False if spec.default else False
# 단, default=True 이지만 preserve_default_on_no_signal=False 인 경우,
# text 약신호라도 있으면 True 유지 여지
if spec.default and text_count > 0:
present = True
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)

227
polygon_reconstructor.py Normal file
View File

@@ -0,0 +1,227 @@
"""개방선(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 typing import Iterable
Point = tuple[float, float]
Segment = tuple[Point, Point]
def _grid_key(p: Point, grid: float) -> tuple[int, int]:
return (int(math.floor(p[0] / grid)), int(math.floor(p[1] / grid)))
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 쌍."""
segs = []
for i in range(len(pts) - 1):
segs.append((pts[i], pts[i + 1]))
return segs
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}")

View 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.txt Normal file
View 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
View 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())

View File

@@ -0,0 +1,379 @@
"""옹벽 3D 파라메트릭 빌더.
구성요소:
1. 본체 (사다리꼴 단면을 길이방향으로 sweep)
2. 기초 slab (하부 넓은 base)
3. 뒤채움 지형 (배면 토사)
4. 배면 앵커바 × N (격자 배치)
5. 상단 안전난간 (parapet)
6. 수축이음 세로선 (표면에 시각화)
7. 배수공 (weep hole)
8. 전면 지반 + 바위
"""
from __future__ import annotations
import math
from typing import Optional
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
H = z_top - z_bot
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: 전면이 안쪽으로 기움
batter_shift = p.front_batter_ratio * H # 전면이 상단에서 안쪽으로 이 만큼 이동
# 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
z_bot = p.bottom_el + p.base_slab_thickness
# 배면에서 뒤로 뻗은 토사 (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
H = p.total_height()
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
rail_spacing = 0.4
for rz in [z0 + p.parapet_height * 0.7, z0 + p.parapet_height * 0.4]:
self._add_box(-L/2, L/2, y_front, y_front + rail_t, rz, rz + rail_t, COLORS["rail"])
# --- 수축이음 (표면 세로선) ---
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)}개 구성요소 생성")

229
retaining_wall_parser.py Normal file
View File

@@ -0,0 +1,229 @@
"""옹벽 (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
from pathlib import Path
from typing import Optional
import ezdxf
import numpy as np
from view_detector import detect_view_regions, ViewRegion
from dxf_geometry import extract_structural_geometry
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, max(ev))
params.bottom_el = min(params.bottom_el, min(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())

6569
scanvas_maker.py Normal file

File diff suppressed because it is too large Load Diff

137
scanvas_maker.spec Normal file
View 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",
)

187
splash.py Normal file
View File

@@ -0,0 +1,187 @@
"""S-CANVAS 인트로 로딩 스플래시.
Design/logo_intro.mp4 를 frameless Toplevel 중앙에 재생한 뒤 자동 종료.
메인 앱(`scanvas_maker.SCanvasApp`) 기동 직전에 호출한다.
기술 스택:
- cv2 (VideoCapture) : MP4 프레임 디코드 (harness/quality_validator 가
이미 의존하는 프로젝트 공통 dep).
- PIL + tkinter.PhotoImage : 프레임 렌더.
- tk.Tk + attributes("-alpha", ...) : frameless + 페이드 인/아웃 효과.
dynamic effects(사용자 요구 "역동적으로"):
1. 시작 시 알파 0 → 1 페이드인 (400ms)
2. 비디오 자체 애니메이션(logo_intro.mp4 는 고유 모션 포함)
3. 종료 전 알파 1 → 0 페이드아웃 (400ms)
4. 비디오 하단에 브랜드 tagline bar (오렌지 italic)
5. max_duration_s 초과 시 강제 종료(safety)
실패 조건(조용히 skip):
- logo_intro.mp4 없음
- cv2/PIL import 실패
- VideoCapture.open 실패
메인 앱 기동은 항상 보장된다.
"""
from __future__ import annotations
import time
import tkinter as tk
from pathlib import Path
def show_intro_splash(
video_path,
max_duration_s: float = 12.0,
fade_ms: int = 400,
tagline: str = "S-CANVAS — Generative Design & Visualization Engine",
max_display_w: int = 1000,
) -> None:
"""video_path 의 MP4 를 스플래시로 재생 후 블로킹 반환.
Args:
video_path: Path-like, .mp4 파일.
max_duration_s: 비디오가 이 시간을 넘기면 강제 종료.
fade_ms: 페이드인/아웃 각 구간 길이(ms).
tagline: 비디오 아래에 표시할 문구.
max_display_w: 화면상 최대 가로(px). 이보다 크면 aspect 유지 축소.
동작:
- 임시 tk.Tk 루트를 만들고 mainloop → 종료 시 완전 destroy.
- 이후 `SCanvasApp()` 이 새 ctk.CTk 인스턴스를 만들어도 충돌 없음
(Tk 루트가 이미 파괴됐으므로).
"""
try:
import cv2
from PIL import Image, ImageTk
except ImportError as e:
print(f"[Intro] cv2/PIL 미설치 — 스플래시 skip ({e})")
return
vp = Path(video_path)
if not vp.exists():
print(f"[Intro] 비디오 없음 ({vp}) — 스플래시 skip")
return
cap = cv2.VideoCapture(str(vp))
if not cap.isOpened():
print(f"[Intro] VideoCapture 열기 실패 — 스플래시 skip")
return
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
frame_ms = max(int(1000.0 / max(fps, 1.0)), 8)
vw = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 800)
vh = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 450)
if vw > max_display_w:
disp_w = max_display_w
disp_h = max(1, int(vh * max_display_w / vw))
else:
disp_w, disp_h = vw, vh
root = tk.Tk()
root.withdraw()
screen_w = root.winfo_screenwidth()
screen_h = root.winfo_screenheight()
TAG_H = 44
total_h = disp_h + TAG_H
x = max(0, (screen_w - disp_w) // 2)
y = max(0, (screen_h - total_h) // 2)
splash = tk.Toplevel(root)
splash.overrideredirect(True)
try:
splash.attributes("-topmost", True)
splash.attributes("-alpha", 0.0)
except tk.TclError:
pass
splash.configure(bg="#0A0F1C")
splash.geometry(f"{disp_w}x{total_h}+{x}+{y}")
vid_label = tk.Label(splash, bg="#0A0F1C", borderwidth=0, highlightthickness=0)
vid_label.place(x=0, y=0, width=disp_w, height=disp_h)
tag_label = tk.Label(
splash, text=tagline,
bg="#0A0F1C", fg="#E67E22",
font=("Segoe UI", 10, "italic"),
borderwidth=0, highlightthickness=0,
)
tag_label.place(x=0, y=disp_h, width=disp_w, height=TAG_H)
state = {"closed": False, "t_start": time.time()}
def _safe_alpha(a):
try:
splash.attributes("-alpha", max(0.0, min(1.0, a)))
except tk.TclError:
pass
def _fade_to(target, duration_ms, done_cb=None):
steps = max(int(duration_ms / 16), 1)
start_alpha = float(splash.attributes("-alpha") or 0.0)
def _step(i):
if state["closed"]:
return
ratio = i / steps
_safe_alpha(start_alpha + (target - start_alpha) * ratio)
if i < steps:
splash.after(16, lambda: _step(i + 1))
elif done_cb:
done_cb()
_step(0)
def _close():
if state["closed"]:
return
state["closed"] = True
try:
cap.release()
except Exception:
pass
try:
splash.destroy()
except Exception:
pass
try:
root.quit()
except Exception:
pass
def _play_next_frame():
if state["closed"]:
return
if (time.time() - state["t_start"]) > max_duration_s:
_fade_to(0.0, fade_ms, done_cb=_close)
return
ret, frame = cap.read()
if not ret:
# 비디오 끝 — 페이드아웃 후 종료
_fade_to(0.0, fade_ms, done_cb=_close)
return
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
if (vw, vh) != (disp_w, disp_h):
rgb = cv2.resize(rgb, (disp_w, disp_h), interpolation=cv2.INTER_AREA)
img = Image.fromarray(rgb)
photo = ImageTk.PhotoImage(img)
vid_label.configure(image=photo)
vid_label.image = photo # GC 방지
splash.after(frame_ms, _play_next_frame)
# 페이드인 → 첫 프레임 재생 시작
_fade_to(1.0, fade_ms, done_cb=_play_next_frame)
try:
root.mainloop()
finally:
try:
root.destroy()
except Exception:
pass
if __name__ == "__main__":
# 단독 테스트
show_intro_splash(Path(__file__).resolve().parent / "Design" / "logo_intro.mp4")

507
structure_placement.py Normal file
View File

@@ -0,0 +1,507 @@
"""구조물 메쉬를 지형 위에 배치하는 유틸.
구조물 빌더들(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
from typing import Optional
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) -> Optional[float]:
"""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: Optional[pv.PolyData] = None,
terrain_origin: Optional[np.ndarray] = None,
z_offset: float = 0.0,
structure_bottom_el: Optional[float] = None,
skip_ground: bool = False,
scale: float = 1.0,
skip_terrain: bool = False,
pad_surface_z: Optional[float] = 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 as e:
# 개별 메쉬 변환 실패시 건너뜀
continue
return out
def fit_meshes_to_quad(
meshes: list[tuple[pv.PolyData, str, float]],
quad_world_pts: list,
terrain_mesh: Optional[pv.PolyData] = None,
terrain_origin: Optional[np.ndarray] = None,
structure_bottom_el: Optional[float] = None,
z_mode: str = "terrain",
z_offset: float = 0.0,
skip_ground: bool = True,
skip_terrain: bool = True,
scale_mode: str = "none",
pad_surface_z: Optional[float] = None,
embed_offset: float = 0.02,
detail_quad_pts: Optional[list] = 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)")

1583
structure_templates.py Normal file

File diff suppressed because it is too large Load Diff

View 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: "렌더링 안 함"

500
structure_vlm_feedback.py Normal file
View File

@@ -0,0 +1,500 @@
"""구조물 상세도면 ↔ 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 base64
import json
import os
import re
from dataclasses import asdict, fields, is_dataclass
from pathlib import Path
from typing import Any, Callable, Optional
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
if isinstance(dxf_paths, (list, tuple)):
dxf_path = dxf_paths[0]
else:
dxf_path = 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}")
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}")
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]}")
else:
raise RuntimeError(f"JSON 파싱 실패: {e}\n원문: {text[:300]}")
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: Optional[dict] = 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: Optional[str] = None,
location: str = "global",
use_vertex: bool = True,
api_key: Optional[str] = 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})")
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(f" [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
View 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 typing 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)

408
valve_chamber_3d_builder.py Normal file
View File

@@ -0,0 +1,408 @@
"""제수변실 + 도수관로 3D 파라메트릭 빌더.
구성요소:
1. 실 본체 (콘크리트 박스)
2. 벽체 절개 뷰 (내부가 보이도록 상부 일부 제거)
3. 도수관 (chamber 관통 파이프)
4. 송수관 (외부 연장)
5. 밸브 × N (원통 + 핸들)
6. 상단 슬라이드 뚜껑 / 맨홀
7. 내부 바닥 slabs (각 EL)
8. 외부 출입 계단
9. 지반
"""
from __future__ import annotations
import math
from typing import Optional
import numpy as np
import pyvista as pv
from valve_chamber_parser import ValveChamberParams, Valve, Pipe
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):
if n == 1:
hx = 0
else:
hx = -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):
if p.hatch_count == 1:
hx = 0
else:
hx = -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)}개 구성요소 생성")

689
valve_chamber_parser.py Normal file
View File

@@ -0,0 +1,689 @@
"""제수변실 (Valve Chamber) + 도수관로 DXF 파서.
구조 특성:
- 콘크리트 실(chamber) 본체
- 내부 밸브 다수 (게이트/버터플라이/체크 등)
- 도수관 (intake main pipe, 주 입수관)
- 송수관 (transmission pipes, 여러 계통)
- 상단 슬라이드 뚜껑/맨홀
- 외부 관로 연장
사용법:
parser = ValveChamberParser()
params = parser.parse(["valve_chamber.dxf"])
"""
from __future__ import annotations
import re
import math
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import ezdxf
import numpy as np
from view_detector import detect_view_regions, ViewRegion
from dxf_geometry import extract_structural_geometry
# ---------------------------------------------------------------------------
# 데이터 클래스
# ---------------------------------------------------------------------------
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: Optional[float] = 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) -> Optional[tuple[float, float]]:
"""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, max(els))
params.bottom_el = min(params.bottom_el, min(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":
for p in e.get_points():
chamber_pts.append((p[0] * scale, p[1] * scale))
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)
for v in txt_valves:
if v.name and v.name not in ml_names:
merged_valves.append(v)
merged_pipes = list(ml_pipes)
for p in txt_pipes:
if p.name and p.name not in ml_names:
merged_pipes.append(p)
# 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 "")
if e.dxftype() == "MTEXT":
txt = _clean_mtext(raw)
else:
txt = 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) -> Optional[float]:
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}")

520
view_detector.py Normal file
View File

@@ -0,0 +1,520 @@
"""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, field
from pathlib import Path
from typing import Optional
import ezdxf
import numpy as np
from dxf_geometry import (
extract_structural_geometry,
GeometryResult,
Shape,
is_excluded_layer,
)
# ---------------------------------------------------------------------------
# 뷰 타입 라벨 패턴
# ---------------------------------------------------------------------------
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: Optional[int] = 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) -> Optional[str]:
"""텍스트가 뷰 라벨인지 확인하고 타입 반환."""
# 매우 긴 텍스트는 라벨이 아닐 가능성 (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, Optional[int]]:
"""텍스트에서 축척 추출 (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) -> Optional[Shape]:
"""라벨 위치에 가장 적합한 사각형을 찾기.
우선순위:
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의 1/2 정도
total_w = geom_bounds[2] - geom_bounds[0]
total_h = geom_bounds[3] - geom_bounds[1]
default_w = total_w * 0.5
default_h = total_h * 0.5
# 이웃 라벨과의 중간 지점까지를 경계로
x_min = geom_bounds[0]
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) -> Optional[ViewRegion]:
"""타입으로 뷰 검색. 같은 타입이 여러 개면 첫 번째."""
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}")

921
view_reconstructor.py Normal file
View File

@@ -0,0 +1,921 @@
"""뷰 기반 통합 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 math
from typing import Optional
import numpy as np
import pyvista as pv
from view_detector import ViewRegion
from dxf_geometry import Shape
# ---------------------------------------------------------------------------
# 템플릿별 색상 / 키워드
# ---------------------------------------------------------------------------
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: Optional[str] = None) -> Optional[ViewRegion]:
"""주어진 타입의 뷰 중 가장 대표적인 것 선택.
선택 기준:
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) -> Optional[list]:
"""뷰에서 구조물의 외곽선을 단일 폴리곤으로 추출.
전략 (실루엣 면적이 뷰 면적의 일정 비율 이상이어야 진짜 외곽으로 간주):
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)
else:
if _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) -> Optional[dict]:
"""점들의 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) -> Optional[dict]:
"""평면도가 가늘고 긴 구조물(옹벽 등)인지 감지.
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
else:
if _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
) -> Optional[tuple]:
"""폴리곤(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
) -> Optional[tuple]:
"""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
) -> Optional[tuple]:
"""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) -> Optional[tuple]:
"""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: Optional[str] = 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: Optional[str],
plan_view: Optional[ViewRegion] = None,
section_silhouette: Optional[list] = None,
) -> Optional[tuple]:
"""가용 실루엣들로부터 단일 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: Optional[str] = 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 여수로 수문 설치도(12).dxf", "spillway_gate"),
("SAMPLE_CAD/12996710-M40-001 신설 취수탑 설비 설치도(12).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(f" 메인 뷰:")
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()