Add source code, design assets, and CAD samples
This commit is contained in:
153
.gitignore
vendored
Normal file
153
.gitignore
vendored
Normal 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
1867
CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
BIN
Design/Logo.png
Normal file
BIN
Design/Logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.1 MiB |
BIN
Design/S-CANVAS_AI_Visualization.pdf
Normal file
BIN
Design/S-CANVAS_AI_Visualization.pdf
Normal file
Binary file not shown.
BIN
Design/SAMAN_CI.gif
Normal file
BIN
Design/SAMAN_CI.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
Design/homepage_sample.png
Normal file
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
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
BIN
Design/logo_intro.mp4
Normal file
Binary file not shown.
BIN
Design/page_sample.png
Normal file
BIN
Design/page_sample.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 594 KiB |
737
S-CANVAS_brief.md
Normal file
737
S-CANVAS_brief.md
Normal file
@@ -0,0 +1,737 @@
|
||||
# S-CANVAS — 프로그램 핵심 소개 (NotebookLM 발표자료 소스)
|
||||
|
||||
> **목적**: 본 문서는 NotebookLM 에 업로드하여 발표 슬라이드, Audio Overview,
|
||||
> 마인드맵 등을 자동 생성하기 위한 **단일 소스 텍스트** 입니다. S-CANVAS
|
||||
> 프로그램의 정체성·아키텍처·핵심 기술·워크플로·차별화 포인트를 한 파일에
|
||||
> 응축했습니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 한 줄 요약
|
||||
|
||||
**S-CANVAS** — *Generative Design & Visualization Engine*. CAD 도면(DXF)과 실제
|
||||
공개 DEM·위성영상·Generative AI 를 결합해 **건설/토목 프로젝트의 3D 조감도와
|
||||
구조물 시각화**를 자동 생성하는 데스크톱 엔진. **Saman Corp.** 자체 개발.
|
||||
|
||||
- 원래 명칭: `EG-VIEW` (AI 기반 조감도 생성 시스템)
|
||||
- 2026-04-24 리브랜딩: `S-CANVAS` (Generative Design & Visualization Engine)
|
||||
- 메인 진입점: `scanvas_maker.py`
|
||||
- 플랫폼: Windows 10/11, Python + CustomTkinter GUI
|
||||
|
||||
---
|
||||
|
||||
## 2. 배경 & 문제 정의
|
||||
|
||||
### 2.1 기존 토목·건설 시각화의 한계
|
||||
|
||||
전통적인 CAD 평면도는 **2D**이며, 등고선·구조물 윤곽만 담겨 있어 실제 지형
|
||||
맥락(주변 산세, 강, 식생, 도로망)이 빠져 있다. 발주처 보고·주민 설명회에서는
|
||||
**"실제 어떻게 보이는가"** 가 중요하지만, 기존 워크플로에서는:
|
||||
|
||||
1. CAD 도면을 3D 모델러(Civil 3D/Revit/Rhino) 로 다시 그리고
|
||||
2. GIS 데이터를 별도로 받아 좌표 정합
|
||||
3. 위성영상을 매핑하고
|
||||
4. 렌더 엔진(Lumion/Twinmotion/V-Ray) 으로 조명·재질·하늘 설정
|
||||
5. 카메라 앵글 잡고 이미지 출력
|
||||
|
||||
이 과정이 **수일에서 수주** 걸리며, 변경이 생길 때마다 대부분의 단계를 다시
|
||||
수행해야 한다.
|
||||
|
||||
### 2.2 S-CANVAS 의 가설
|
||||
|
||||
> *"DXF 등고선·구조물 레이어 + 공개 DEM + 위성타일 + Generative AI 만으로,
|
||||
> 클릭 4번에 사실 기반 3D 조감도를 만들 수 있다."*
|
||||
|
||||
- DXF 의 등고선 → TIN(Triangulated Irregular Network) 자동 생성
|
||||
- DXF 범위 밖 지형 → AWS Open Terrain Tiles(글로벌 DEM) 자동 채움
|
||||
- 표면 텍스처 → Google/ArcGIS/Vworld 위성 타일을 UV 매핑
|
||||
- 최종 사실감 향상 → Gemini/Stability AI 가 **구조 보존 모드**로 재렌더
|
||||
|
||||
### 2.3 핵심 가치 제안 (Value Proposition)
|
||||
|
||||
| 기존 | S-CANVAS |
|
||||
|---|---|
|
||||
| 모델링 수일 | TIN 생성 30초 |
|
||||
| 외부 라이선스 GIS | 공개 DEM 자동 페치 |
|
||||
| 수동 좌표 정합 | 4점 매칭 자동 GeoRef |
|
||||
| 렌더 1회 수시간 | AI 렌더 1분 이내 |
|
||||
| 변경 → 재모델링 | 변경 → 재실행 (캐시 재활용) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 시스템 아키텍처
|
||||
|
||||
### 3.1 모듈 구성 (Top Level)
|
||||
|
||||
```
|
||||
scanvas_maker.py # 메인 GUI 엔트리 (~6300 LOC, CustomTkinter)
|
||||
├── splash.py # 인트로 MP4 스플래시 (cv2 + Tk Toplevel)
|
||||
├── dem_extender.py # AWS Terrarium DEM 페치 + 도넛 링 메시
|
||||
├── geo_referencing.py # 4점 매칭 GeoReferencing 워크플로
|
||||
├── structure_placement.py # 구조물 위치/방향 자동 인식 + TIN 굴착
|
||||
├── structure_templates.py # 구조물 유형 레지스트리 (Registry 패턴)
|
||||
├── intake_tower_3d_builder.py # 취수탑 3D
|
||||
├── intake_tower_parser.py # 취수탑 DXF 치수 파싱
|
||||
├── retaining_wall_3d_builder.py # 옹벽 3D
|
||||
├── retaining_wall_parser.py
|
||||
├── gate_3d_builder.py # 수문 3D
|
||||
├── gate_parser.py
|
||||
├── valve_chamber_3d_builder.py # 제수변실 3D
|
||||
├── valve_chamber_parser.py
|
||||
├── detail_parser.py # 상세도면 DXF 치수 파서 (TEXT/MTEXT/DIMENSION)
|
||||
├── filename_classifier.py # 파일명 → 구조물 유형 추정
|
||||
├── polygon_reconstructor.py
|
||||
├── dxf_geometry.py # DXF 엔티티 → 좌표
|
||||
├── tile_downloader.py # 위성 XYZ 타일 다운로드
|
||||
├── view_detector.py / view_reconstructor.py # 도면 뷰 영역 인식
|
||||
├── optional_detector.py
|
||||
├── gemini_renderer.py # Gemini API/Vertex AI 렌더링 워커
|
||||
├── structure_vlm_feedback.py # Gemini Vision 으로 구조물 결과 검증
|
||||
├── egview_maker.py 의 후신: # (이전 명칭, 현재 scanvas_maker.py 로 통합/리네임)
|
||||
│
|
||||
└── harness/ # 품질·재현성·이력 서브시스템
|
||||
├── seed_manager.py # DXF 해시 기반 결정론적 seed
|
||||
├── quality_validator.py # OpenCV 기반 이미지 자동 QA
|
||||
├── prompt_registry.py # 프롬프트 버전 관리 (YAML)
|
||||
└── logger.py # SQLite ORM (JobRecord) + structlog
|
||||
|
||||
prompt_templates/prompt_v1.yaml # 렌더 프롬프트 (시간대/앙각/구조보존)
|
||||
structure_types/structure_v1.yaml # 구조물 유형 정의
|
||||
|
||||
Design/ # 브랜딩 자산
|
||||
├── logo_V2.png # 로고 (다크 bg → 런타임 스트립)
|
||||
├── Logo.png # 이전 로고 (투명 bg)
|
||||
├── SAMAN_CI.gif # Saman Corp 크레딧
|
||||
├── logo_intro.mp4 # 인트로 스플래시 (8s, 24fps, 1280×720)
|
||||
├── homepage_sample.png # UI 디자인 레퍼런스
|
||||
└── page_sample.png
|
||||
|
||||
cache/dem/ # AWS Terrarium 타일 캐시
|
||||
scanvas_jobs.db # SQLite 작업 이력 (Harness)
|
||||
scanvas_harness.log # structlog 출력
|
||||
scanvas_diagnostic.log # Step 1 진단 로그
|
||||
```
|
||||
|
||||
### 3.2 데이터 흐름 (Pipeline)
|
||||
|
||||
```
|
||||
[DXF 도면] → [레이어 분류] → [등고선/구조물 분리]
|
||||
↓
|
||||
[GeoRef 4점 매칭] → [투영 CRS 결정 (EPSG:5187 등)]
|
||||
↓
|
||||
[TIN 생성 (Delaunay)] → [TIN core 정밀 영역 지정 (선택)]
|
||||
↓
|
||||
[DEM 자동 페치 (AWS Terrarium)] → [도넛 링 메시] → [smoothstep blend]
|
||||
↓
|
||||
[seam-free 통합 메시 (merge_points + compute_normals)]
|
||||
↓
|
||||
[위성 타일 다운로드] → [UV 매핑 (texture_map_to_plane)]
|
||||
↓
|
||||
[3D 미리보기 (PyVista)] → [카메라 앵글 캡처] → [제어맵]
|
||||
↓
|
||||
[Gemini/Stability AI 렌더링 (구조 보존 모드)]
|
||||
↓
|
||||
[QualityValidator 자동 검증] → [scanvas_jobs.db 이력 기록]
|
||||
↓
|
||||
[고해상도 PNG 출력]
|
||||
```
|
||||
|
||||
### 3.3 외부 의존성
|
||||
|
||||
**Python 라이브러리** (주요):
|
||||
- `customtkinter` — 모던 Tk GUI 프레임워크
|
||||
- `tkintermapview` — 위성지도 뷰
|
||||
- `ezdxf` — DXF 파싱
|
||||
- `numpy`, `scipy` — 수치 연산, Delaunay
|
||||
- `pyvista` + `vtk` — 3D 메시 렌더링
|
||||
- `pyproj` — 좌표계 변환
|
||||
- `Pillow (PIL)` — 이미지 처리
|
||||
- `opencv-python (cv2)` — 비디오 / 이미지 검증
|
||||
- `requests` — HTTP 타일 페치
|
||||
- `google-genai` — Gemini SDK (Vertex AI 또는 API Key)
|
||||
- `sqlalchemy` + `structlog` — 이력 추적
|
||||
- `PyYAML` — 프롬프트/구조물 정의
|
||||
|
||||
**외부 데이터 소스**:
|
||||
- AWS Open Terrain Tiles (`s3.amazonaws.com/elevation-tiles-prod/terrarium`)
|
||||
— 글로벌 DEM, ~30m 정확도, API 키 불필요
|
||||
- Google Satellite / ArcGIS World Imagery / Bing Aerial / Vworld
|
||||
— 위성 타일 (사용자 선택)
|
||||
- Google Vertex AI / Gemini API — 생성형 렌더링
|
||||
- (옵션) Stability AI API — img2img 폴백
|
||||
|
||||
---
|
||||
|
||||
## 4. 핵심 워크플로 (Step-by-Step)
|
||||
|
||||
### Step 0 — DXF 로드 & 레이어 분류
|
||||
|
||||
DXF 를 열면 자동으로 레이어를 분석해 **구조물 유형**(취수탑/제수변실/옹벽/수문/
|
||||
지형 등고선/도로/하천 등)을 추정. `filename_classifier.py` 가 파일명 패턴(예:
|
||||
"신설 취수탑.dxf") 으로 1차 후보를 제시하고, 사용자가 GUI 다이얼로그에서 최종
|
||||
확정. 결과는 `structure_v1.yaml` 의 정의에 따라 빌더로 라우팅.
|
||||
|
||||
### Step 1 — TIN 생성 (DXF)
|
||||
|
||||
지형 레이어의 등고선 점·LINE/LWPOLYLINE 정점을 추출 → Delaunay 삼각화 →
|
||||
**zero-basing** (원점을 도면 bbox 좌하단으로 평행이동, 부동소수점 정밀도 보존) →
|
||||
PyVista PolyData 메시 생성.
|
||||
|
||||
**핵심 디테일**:
|
||||
- 단위 자동 감지 → KATEC 좌표(EPSG:5187 등) 는 항상 m 로 강제 (`unit_override="m"`)
|
||||
- DEM datum offset 사전 계산 → 이후 단계에서 **공통 offset 재사용**으로 seam Z 단차 0 보장
|
||||
- DEM 격자도 사전 페치 → `_dem_elev_grid` / `_dem_grid_bounds` 로 캐시
|
||||
|
||||
### Step 1.5 — DEM 으로 TIN 확장
|
||||
|
||||
DXF 범위 밖이 절벽처럼 잘리는 문제 해결. AWS Terrarium 에서 **외곽 도넛 링**을
|
||||
받아 TIN 과 이어 붙임.
|
||||
|
||||
**3-Zone 구조** (메모리화된 핵심 결정):
|
||||
1. **Core** (`tin_core_bbox` 안): 원본 TIN 100% 보존. 사용자가 정밀 영역으로 지정.
|
||||
2. **Transition** (core ~ bbox, `blend_width_m`): smoothstep `3t² − 2t³` 으로 TIN ↔ DEM Z 부드럽게 블렌드.
|
||||
3. **DEM 외곽 링**: AWS DEM 으로 채움.
|
||||
|
||||
**Seam 처리 (다층 방어)**:
|
||||
- **수직 datum 보정**: TIN 경계 근처 점들의 (TIN Z – DEM Z) 중앙값을 offset 으로 차감
|
||||
- **공유 정점 weld**: TIN bbox 변 정점을 DEM 링 inner 경계로 강제 → 동일 XY 공유
|
||||
- **smoothstep feather**: 경계 0 = TIN, feather_m = DEM 으로 C¹ 연속
|
||||
- **벽 컷 (slope_ratio 기반)**: `slope_ratio = z_span / max_edge` 가 4.0 (≈76°) 초과 + Z스팬 30m + max_edge 5m 충족 시 "벽 삼각형" 으로 컷. **절대 Z 컷은 절대 사용하지 않음** (구멍이 뚫림 — 메모리화된 교훈)
|
||||
- **링 Laplacian 1pass**: 톱니 fin 마지막 평활. 경계 정점은 pin
|
||||
|
||||
### Step 2 — 위성지도 결합 (Draping)
|
||||
|
||||
선택한 타일 서버에서 **확장된 bbox 전체**의 위성 이미지 다운로드 → 단일 큰
|
||||
이미지로 합성 → `texture_map_to_plane` 으로 UV 매핑 → 텍스처 입힌 3D 메시.
|
||||
|
||||
**Seam-free 통합 렌더링** (2026-04-24 후속 수정):
|
||||
- 이전: `tin_mesh` 와 `tin_extension_mesh` 가 두 개의 별도 PolyData → 경계에서
|
||||
쉐이딩 불연속 (사각 선이 보이는 증상)
|
||||
- 수정: `merge(merge_points=True, tolerance=0.01)` 로 공유 정점 weld → 위상적
|
||||
단일 표면. `compute_normals(feature_angle=180)` 로 모든 edge smooth → seam
|
||||
법선 평활. UV 좌표는 `_uv_mapping_params` 저장 후 merge 후 재적용.
|
||||
|
||||
### Step 3 — 제어맵 추출
|
||||
|
||||
PyVista 카메라 앵글로 오프스크린 캡처:
|
||||
- `capture_textured.png` — 위성+DEM 합성된 control map
|
||||
- `capture_depth.png` — 깊이 맵 (Eye-Dome Lighting 활성화)
|
||||
|
||||
이 2개 이미지가 Step 4 의 AI 렌더 입력.
|
||||
|
||||
### Step 4 — AI 렌더링 (구조 보존 모드)
|
||||
|
||||
`prompt_templates/prompt_v1.yaml` 기반으로 프롬프트 구성:
|
||||
- **시간대 프리셋**: daytime / sunset / night / dawn / overcast
|
||||
- **앙각 프리셋**: top_down / high_angle / oblique / low_angle
|
||||
- **구조 보존 지시**: "maintain exact terrain shape, contours, and layout from the input image"
|
||||
- **품질 향상**: "8K ultra sharp detail, professional drone photography quality"
|
||||
- **네거티브**: "blurry, distorted, watermark, changed terrain layout, moved structures..."
|
||||
|
||||
**렌더 엔진 3종**:
|
||||
1. **Gemini (Vertex AI)** — google-genai SDK, GCP 인증
|
||||
2. **Gemini (API Key)** — google-genai SDK, AI Studio 키
|
||||
3. **Stability AI (API)** — 3단계 폴백:
|
||||
1. Conservative Upscale (원본 보존 최우선)
|
||||
2. Creative Upscale (조금 더 창의적)
|
||||
3. img2img 초저강도 (strength=0.2)
|
||||
|
||||
각 렌더 호출은 Harness 가 **결정론적 seed** + **prompt 버전** + **품질 점수**를
|
||||
자동 기록 → 동일 입력에 동일 출력 보장.
|
||||
|
||||
---
|
||||
|
||||
## 5. 핵심 기술 컴포넌트 / 혁신 포인트
|
||||
|
||||
### 5.1 DEM 자동 통합 (`dem_extender.py`)
|
||||
|
||||
- **AWS Open Terrain Tiles** terrarium PNG 디코딩:
|
||||
`elev_m = (R*256 + G + B/256) - 32768`
|
||||
- 좌표계 변환: 투영 CRS(예: EPSG:5187) → WGS84 → 타일 좌표 (Web Mercator z/x/y)
|
||||
- 캐시: SHA1(bbox+zoom) 으로 PNG 캐시 → 재실행 시 네트워크 0
|
||||
- **로컬 GeoTIFF 우선**: `cache/dem/local.tif` 가 있으면 NGII 5m DEM 등 고정밀
|
||||
데이터 우선 사용 (rasterio 필요)
|
||||
|
||||
### 5.2 Seam-free 메시 통합 (Render-fix 2026-04-24)
|
||||
|
||||
> 원래 메모리: "벽 이슈는 create_tin_from_dxf 부터 점검. 판정은 slope_ratio 만,
|
||||
> 절대 Z 컷은 구멍 낸다."
|
||||
|
||||
**문제**: TIN+DEM 을 두 개 별도 PolyData 로 add_mesh 하면 normal 평균이 메시
|
||||
경계를 못 넘어 사각 쉐이딩 선이 보임.
|
||||
|
||||
**해결 (1순위 + 2순위 동시)**:
|
||||
```python
|
||||
merged = target.merge(ext_mesh, merge_points=True, tolerance=0.01)
|
||||
if not isinstance(merged, pv.PolyData):
|
||||
merged = merged.extract_surface()
|
||||
# 텍스처 모드: UV 재적용 (TCoords 가 merge 에서 소실되므로)
|
||||
if textured and uv_params:
|
||||
merged = merged.texture_map_to_plane(origin=..., point_u=..., point_v=...,
|
||||
inplace=False)
|
||||
merged.compute_normals(feature_angle=180.0, auto_orient_normals=True,
|
||||
consistent_normals=True, inplace=True)
|
||||
```
|
||||
|
||||
- `merge_points=True` → 공유 경계 정점 **물리적 weld** (1순위 unified Delaunay 효과)
|
||||
- `feature_angle=180` → 모든 edge smooth, 경계 normal 평균 (2순위 normal averaging 효과)
|
||||
- 폴백: 실패 시 기존 2-mesh 렌더 경로 유지
|
||||
|
||||
### 5.3 구조물 자동 빌드 (`structure_*_3d_builder.py`)
|
||||
|
||||
| 유형 | 빌더 | 입력 |
|
||||
|---|---|---|
|
||||
| 취수탑 | `intake_tower_3d_builder.py` | DXF 평면도 → 외곽 폴리곤 + 높이 |
|
||||
| 옹벽 | `retaining_wall_3d_builder.py` | DXF 단면도 → 단면 + 길이 |
|
||||
| 수문 | `gate_3d_builder.py` | DXF 정면도 → 게이트 형상 + 슬라브 |
|
||||
| 제수변실 | `valve_chamber_3d_builder.py` | DXF 평면도 → 박스 + 슬래브 + 점검구 |
|
||||
|
||||
**구조물 위치인식 → 굴착 → TIN수정 → 3D배치** (메모리화된 워크플로):
|
||||
1. DXF 레이어에서 폴리곤 인식
|
||||
2. PCA 로 frame 방향 결정 (`compute_orientation_from_points`)
|
||||
3. TIN 에 **굴착 pad** 영역 평탄화 + smoothstep 전이 + Delaunay 재계산
|
||||
4. 메시 4개 quad 코너에 fit (`fit_meshes_to_quad`)
|
||||
5. 구조물 메시를 `embed_offset` 으로 약간 묻어 TIN 관통 방지
|
||||
|
||||
**메시 방향 보정** (메모리화된 디테일):
|
||||
- CW(시계방향) picks + Y-flip + detail/TIN 상대회전 + PCA frame_angle
|
||||
→ 앞뒤 뒤집힘 문제 해결
|
||||
|
||||
### 5.4 VLM 피드백 루프 (`structure_vlm_feedback.py`)
|
||||
|
||||
Gemini Vision 으로 **빌더 결과물의 시각적 정합성**을 자동 검증.
|
||||
- Render 후 이미지 + 원본 DXF 평면도를 함께 모델에 보냄
|
||||
- 차이점 자연어로 응답 받음
|
||||
- 사용자에게 표시해 재시도 결정
|
||||
|
||||
### 5.5 Geo-Referencing 3단계 (`geo_referencing.py`)
|
||||
|
||||
> 메모리: "미리보기 → 위치설정(4점 매칭) → 확정"
|
||||
|
||||
1. **미리보기**: DXF bbox 를 위성지도에 임시 투영 (대략 위치)
|
||||
2. **위치설정**: 사용자가 DXF 의 4 모서리를 실제 위성지도 위치와 매칭 클릭
|
||||
3. **확정**: Affine transform 행렬 계산 → 투영 CRS(EPSG:5187 등) 자동 결정
|
||||
|
||||
### 5.6 Harness — 재현성 + 품질 + 이력
|
||||
|
||||
세 컴포넌트가 모든 AI 렌더 호출을 감싼다:
|
||||
|
||||
**SeedManager**: `SHA256(dxf_file_hash)[:4] → uint32 seed`. 같은 DXF → 같은 seed.
|
||||
|
||||
**QualityValidator**: 3개 게이트
|
||||
1. 해상도 ≥ 1024px
|
||||
2. Laplacian variance ≥ 50.0 (선명도)
|
||||
3. HSV saturation 평균 ≥ 0.15 (단색 평면 출력 탐지)
|
||||
|
||||
**JobLogger** + SQLite `JobRecord` ORM:
|
||||
```
|
||||
id · dxf_path · dxf_hash · timestamp
|
||||
seed · prompt_version · prompt_hash ← 재현성 3종
|
||||
status (pending/running/done/failed)
|
||||
output_path · quality_score · latency_ms
|
||||
error_message
|
||||
```
|
||||
|
||||
**PromptRegistry**: `prompt_v*.yaml` 버전 관리. 비교/저장/해시 역조회 API.
|
||||
|
||||
---
|
||||
|
||||
## 6. GUI 디자인 & 브랜딩
|
||||
|
||||
### 6.1 디자인 철학 (homepage_sample 참고)
|
||||
|
||||
- **Light 기본 테마** (사용자가 Dark 토글 가능)
|
||||
- **카드형 시각 계층화**: 헤더 → SETTINGS → WORKFLOW → OPTIONS → 크레딧 푸터
|
||||
- **1px 구분선**: `("#DEE2E6", "#3F3F3F")` 테마 쌍으로 자동 스위칭
|
||||
- **uppercase 섹션 헤더**: 10pt bold muted gray
|
||||
- **메인 액션 강조**: Step 4 버튼만 Saman 오렌지 `#E67E22`, 나머지는 blue 테마 파생
|
||||
|
||||
### 6.2 사이드바 구조
|
||||
|
||||
```
|
||||
[ S-CANVAS Logo (logo_V2.png, 다크 bg 소프트 스트립) ]
|
||||
Generative Design & Visualization Engine
|
||||
─────────────────────────────────
|
||||
SETTINGS
|
||||
Satellite Source / Vworld Key (프리필 완료) / AI Engine /
|
||||
GCP/API Key / Vertex Location / Project CRS
|
||||
─────────────────────────────────
|
||||
WORKFLOW
|
||||
1. TIN 생성 (DXF) [filled blue]
|
||||
🎯 TIN 이용 범위 (정밀 구역) [outlined]
|
||||
1.5 DEM으로 TIN 확장 [outlined]
|
||||
2. 위성지도 결합 [outlined]
|
||||
3. 제어맵 추출 [outlined]
|
||||
4. AI 렌더링 [filled ORANGE — primary CTA]
|
||||
구조물 상세 3D 빌드 [filled green]
|
||||
간단 치수 추가 (구) [muted]
|
||||
🗔 3D 뷰 다시 열기 [filled dark]
|
||||
─────────────────────────────────
|
||||
OPTIONS
|
||||
□ 와이어프레임 보기
|
||||
뷰 버퍼 (%) [Step2/3]
|
||||
□ 지형 확장 (DEM)
|
||||
[Light ▼]
|
||||
─────────────────────────────────
|
||||
[ Saman CI (흰배경 알파 변환) ]
|
||||
```
|
||||
|
||||
### 6.3 메인 영역
|
||||
|
||||
- **map_frame**: tkintermapview, corner_radius=12, border_color 테마쌍
|
||||
- **textbox**: Consolas 12pt 로그
|
||||
- **status_bar**: `● READY` 인디케이터 + 한 줄 상태
|
||||
|
||||
### 6.4 인트로 스플래시 (`splash.py`)
|
||||
|
||||
- **트리거**: `__main__` 에서 `SCanvasApp()` 생성 직전
|
||||
- **재생**: cv2 VideoCapture 로 logo_intro.mp4 (24fps, 8s, 1280×720) 프레임 디코드 → PIL → Tk Label
|
||||
- **효과**:
|
||||
- 알파 0→1 페이드인 400ms
|
||||
- MP4 자체 애니메이션 재생
|
||||
- 알파 1→0 페이드아웃 400ms 후 destroy
|
||||
- frameless overrideredirect, topmost, 화면 중앙 배치
|
||||
- 비디오 아래 44px 오렌지 italic tagline bar
|
||||
- **안정성**:
|
||||
- max_duration_s=12 safety cap
|
||||
- 비디오 끝 자동 감지 → 페이드아웃
|
||||
- 임시 tk.Tk → 완전 destroy → SCanvasApp() 의 새 ctk.CTk 충돌 없음
|
||||
- 파일 없음/cv2 실패 → silent skip
|
||||
|
||||
### 6.5 자산 처리 헬퍼
|
||||
|
||||
```python
|
||||
_load_image_strip_white_bg(path, threshold=240) # 흰 배경 → 알파 0 (SAMAN_CI)
|
||||
_load_image_strip_dark_bg(path, v_low=30, v_high=80) # 어두운 bg 소프트 전이 (logo_V2)
|
||||
```
|
||||
|
||||
logo_V2 결과: 36.7% 완전투명 / 49.5% 부분알파 / 13.8% 불투명 → halo 없는 부드러운 엣지.
|
||||
|
||||
---
|
||||
|
||||
## 7. AI 렌더링 파이프라인 상세
|
||||
|
||||
### 7.1 프롬프트 구성 (`prompt_v1.yaml`)
|
||||
|
||||
```yaml
|
||||
time_presets:
|
||||
daytime: "bright daylight, clear blue sky, sharp shadows, vivid green vegetation"
|
||||
sunset: "golden hour sunset, warm orange light, long dramatic shadows"
|
||||
night: "nighttime aerial view, moonlight reflections, city lights in distance"
|
||||
dawn: "early dawn, soft pink and purple sky, morning mist over valleys"
|
||||
overcast: "overcast sky, diffused soft light, muted colors, atmospheric fog"
|
||||
|
||||
angle_presets:
|
||||
top_down: "top-down overhead aerial view, directly above"
|
||||
high_angle: "high-angle bird's-eye view, slightly tilted"
|
||||
oblique: "oblique aerial perspective, 3/4 view showing terrain depth"
|
||||
low_angle: "low-angle dramatic perspective, cinematic sweep"
|
||||
|
||||
structure_preservation:
|
||||
- "enhance the existing satellite terrain texture and details"
|
||||
- "maintain exact terrain shape, contours, and layout from the input image"
|
||||
- "preserve water bodies, roads, and structural positions precisely"
|
||||
- "do NOT add or remove any major landscape features"
|
||||
|
||||
quality_enhancement:
|
||||
- "photorealistic architectural visualization"
|
||||
- "professional drone photography quality"
|
||||
- "8K ultra sharp detail, high dynamic range"
|
||||
- "realistic vegetation depth and canopy textures"
|
||||
|
||||
negative_prompt: |
|
||||
blurry, low quality, distorted, watermark, text, logo,
|
||||
cartoon, anime, illustration, painting, sketch,
|
||||
oversaturated, underexposed, noisy, artifacts,
|
||||
changed terrain layout, moved structures, wrong topology
|
||||
```
|
||||
|
||||
### 7.2 Stability AI 3단계 폴백
|
||||
|
||||
**1단계 — Conservative Upscale**
|
||||
- mode: "conservative"
|
||||
- creativity: UI 슬라이더 값 그대로 (기본 0.3)
|
||||
- 목표: 원본 위성 텍스처 최대 보존
|
||||
|
||||
**2단계 — Creative Upscale**
|
||||
- mode: "creative"
|
||||
- creativity: min(strength, 0.35)
|
||||
- 목표: 약간 더 사실적인 디테일 추가
|
||||
|
||||
**3단계 — img2img 초저강도**
|
||||
- strength: min(strength, 0.2)
|
||||
- 모델: sd3.5-large
|
||||
- 목표: 위 2개 실패 시 백업
|
||||
|
||||
각 단계 실패 → 다음 단계 자동 폴백. 3개 모두 실패 시 `fail_job(error="모든 API 방법 실패")`.
|
||||
|
||||
### 7.3 Gemini 경로 (`gemini_renderer.py`)
|
||||
|
||||
- Vertex AI: google-genai SDK, GCP Project ID + gcp-key.json
|
||||
- API Key: aistudio.google.com 발급 키
|
||||
- 동일 인터페이스로 호출 → 인증만 다름
|
||||
|
||||
---
|
||||
|
||||
## 8. 좌표계 / 단위 / 정밀도
|
||||
|
||||
### 8.1 지원 CRS (사용자 선택)
|
||||
|
||||
| EPSG | 이름 | 사용 영역 |
|
||||
|---|---|---|
|
||||
| 5187 | Korea 2000 / Central Belt 2010 | 한국 중부(서울·경기·충청) |
|
||||
| 5186 | Korea 2000 / Central Belt | 한국 중부 (구) |
|
||||
| 5185 | Korea 2000 / West Belt 2010 | 한국 서부 |
|
||||
| 5181 | Korea 2000 / Unified CS | 통합 좌표계 |
|
||||
| 3857 | Web Mercator | 글로벌 (위성 타일 호환) |
|
||||
|
||||
### 8.2 단위 강제 (메모리화된 결정)
|
||||
|
||||
> "extract_tin_shapes 는 unit_override='m' 고정 (자동감지가 KATEC 좌표를 mm 로 오판)"
|
||||
|
||||
DXF `$INSUNITS` 헤더가 부정확한 경우가 많아 **항상 m 로 강제**. KATEC 류
|
||||
대형 좌표값(예: x=200000, y=550000) 을 mm 로 오판하면 단위가 1000배 어긋남.
|
||||
|
||||
### 8.3 Zero-Basing
|
||||
|
||||
큰 절대좌표(예: EPSG:5187 의 200,000m+)는 float32 정밀도에서 sub-meter 오차
|
||||
발생 → **bbox 좌하단을 origin 으로 평행이동** → 모든 메시는 zero-based.
|
||||
구조물 빌더는 절대좌표(`structure_v1.yaml` 의 origin) 와 zero-based 사이를
|
||||
명시적으로 변환.
|
||||
|
||||
---
|
||||
|
||||
## 9. 메모리화된 핵심 결정 사항 (요약)
|
||||
|
||||
> 이 결정들은 과거 디버깅에서 얻은 교훈으로, 코드 곳곳의 분기 판단 기준이 됨.
|
||||
|
||||
1. **Structure Placement Workflow**: 위치인식 → 굴착 → TIN수정 → 3D배치 4단계
|
||||
2. **Geo-Referencing**: 미리보기 → 4점 매칭 → 확정 3단계
|
||||
3. **TIN Shape Unit**: extract_tin_shapes 는 항상 `unit_override="m"` (자동감지 금지)
|
||||
4. **Excavation**: 폴리곤 평탄 pad + smoothstep 전이 + Delaunay 재계산.
|
||||
구조물은 `embed_offset` 으로 TIN 관통 방지
|
||||
5. **Mesh Orientation**: CW picks + Y-flip + 상대회전 + PCA frame_angle 로
|
||||
앞뒤 뒤집힘 해결
|
||||
6. **TIN/DEM 벽 근본 접근**: 벽 이슈는 `create_tin_from_dxf` 부터 점검. 판정은
|
||||
`slope_ratio (z_span / max_edge)` 만 사용. **절대 Z 컷은 구멍 낸다**
|
||||
7. **TIN 3-Zone**: core(원본 TIN) / transition(smoothstep) / DEM 확장.
|
||||
`tin_core_bbox` · `blend_width_m` 로 제어
|
||||
8. **CHANGELOG 의무**: 모든 수정은 `CHANGELOG.md` 에 즉시 기록 (역순, 날짜/파일/사유/diff 요지)
|
||||
|
||||
---
|
||||
|
||||
## 10. 최근 주요 수정 이력 (2026-04-24 ~)
|
||||
|
||||
### [render-fix] DEM 확장 경계 사각 선 제거 (후속)
|
||||
- 두 개의 별도 PolyData → seam shading discontinuity 발생
|
||||
- `merge(merge_points=True, tolerance=0.01)` + `compute_normals(feature_angle=180)`
|
||||
로 1순위(구조 통합) + 2순위(법선 평활) 동시 달성
|
||||
- 텍스처 모드에서 TCoords 소실 → `_uv_mapping_params` 저장 후 merge 후 재적용
|
||||
|
||||
### [rebrand] EG-VIEW → S-CANVAS 전면 리네이밍
|
||||
- 클래스 `EGViewApp → SCanvasApp`
|
||||
- 파일 `egview_maker.py → scanvas_maker.py`
|
||||
- DB/로그 `egview_*.db/log → scanvas_*.db/log`
|
||||
- 144개 문자열 occurrence (43 파일) 일괄 교체
|
||||
|
||||
### [ui-redesign] Light 기본 테마 + 사이드바 카드형
|
||||
- `set_appearance_mode("light")`
|
||||
- 모든 하드코딩 색상을 `(light, dark)` 튜플 쌍으로 → 자동 테마 스위칭
|
||||
- SETTINGS / WORKFLOW / OPTIONS 섹션 헤더 + 1px 구분선
|
||||
- SAMAN_CI 흰 배경 68.5% 알파 변환
|
||||
|
||||
### [feature] Vworld 키 프리필 + logo_V2 + 인트로 스플래시
|
||||
- Vworld API 키 하드코딩 (사용자 제공)
|
||||
- 로고 `Logo.png → logo_V2.png` (다크 bg 소프트 스트립)
|
||||
- 신규 `splash.py` — cv2 기반 8초 MP4 스플래시 + 페이드 인/아웃
|
||||
- `__main__` 에서 `show_intro_splash()` → `SCanvasApp()` 순차 실행
|
||||
|
||||
---
|
||||
|
||||
## 11. 차별화 포인트 (발표 슬라이드용 강조)
|
||||
|
||||
### 11.1 **사실 기반 vs 환각 기반**
|
||||
경쟁 도구가 AI 의 상상으로 지형을 만들 때, S-CANVAS 는 **실측 DEM + 실제 위성영상**
|
||||
을 베이스로 깔고 AI 는 **사실감 향상만** 담당. → 발주처에 실제와 다른 지형을
|
||||
제출하는 사고 원천 차단.
|
||||
|
||||
### 11.2 **Seam-free 통합 메시**
|
||||
TIN(설계 도면) + DEM(실제 지형) + 위성텍스처 + AI 렌더가 모두 **하나의 연속
|
||||
표면**으로 합쳐짐. 사용자가 어디까지가 도면이고 어디부터가 실제 지형인지 구분
|
||||
못 하도록.
|
||||
|
||||
### 11.3 **결정론적 재현성** (Harness)
|
||||
DXF 해시 → seed → AI 렌더. 같은 입력에 항상 같은 출력. 발주처에 제출했던 그림과
|
||||
1주일 뒤 회의에서 띄울 그림이 픽셀 단위로 동일.
|
||||
|
||||
### 11.4 **모듈러 구조물 빌더**
|
||||
취수탑·제수변실·옹벽·수문 4종 즉시 지원. `structure_v1.yaml` 에 정의 추가하면
|
||||
새 유형 확장 가능. DXF 단면도 → 자동 치수 파싱 → 3D 배치까지 클릭 1번.
|
||||
|
||||
### 11.5 **공개 데이터 + 무료 티어**
|
||||
AWS Open Terrain Tiles · Google Satellite · ArcGIS World Imagery 모두 무료.
|
||||
유일한 유료 항목은 AI 렌더(Gemini/Stability) — 그것도 사용자 본인 키 사용.
|
||||
|
||||
### 11.6 **자동 품질 게이트**
|
||||
모든 렌더 결과를 OpenCV 로 즉시 검증. 흐릿하거나 단색 출력은 자동 PASS/FAIL
|
||||
표시 → 사용자가 100장을 일일이 확인할 필요 없음.
|
||||
|
||||
### 11.7 **Saman Corp 자체 개발**
|
||||
국내 토목·건설 도메인 지식이 코드 곳곳에 — KATEC 단위 처리, 한국 EPSG 코드
|
||||
프리셋, Vworld 타일 지원, 한글 UI·로그 등.
|
||||
|
||||
---
|
||||
|
||||
## 12. 기술 스택 요약 (한눈에)
|
||||
|
||||
```
|
||||
[Frontend / GUI]
|
||||
└─ CustomTkinter (Tk 기반 모던 위젯) + tkintermapview
|
||||
|
||||
[3D Engine]
|
||||
└─ PyVista + VTK (메시 처리·렌더링)
|
||||
|
||||
[Geospatial]
|
||||
├─ ezdxf (DXF 파싱)
|
||||
├─ pyproj (좌표 변환)
|
||||
├─ scipy.spatial.Delaunay (TIN)
|
||||
└─ AWS Open Terrain Tiles (DEM)
|
||||
|
||||
[Image / Video]
|
||||
├─ Pillow (PIL)
|
||||
├─ OpenCV (cv2)
|
||||
└─ tkintermapview (위성 타일)
|
||||
|
||||
[AI Rendering]
|
||||
├─ Google google-genai (Vertex AI / API Key)
|
||||
└─ Stability AI REST API
|
||||
|
||||
[Persistence]
|
||||
├─ SQLAlchemy + SQLite (JobRecord)
|
||||
├─ structlog (구조화 로깅)
|
||||
└─ PyYAML (프롬프트/구조물 정의)
|
||||
|
||||
[Build / Distribution] (계획)
|
||||
└─ PyInstaller (단일 .exe 또는 onedir 배포)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. 배포 / 패키징 계획
|
||||
|
||||
**PyInstaller** 로 Windows .exe 빌드 예정. 주요 챌린지:
|
||||
- PyVista + VTK 200MB+ 바이너리 → `--collect-all pyvista vtkmodules` 필요
|
||||
- pyproj PROJ data → 명시적 hook
|
||||
- google-genai → hidden imports
|
||||
- 런타임 쓰기 경로(DB·로그·캐시) → `%LOCALAPPDATA%\S-CANVAS\` 분리 (onedir/onefile 양쪽)
|
||||
- 자산 경로 (`Design/`, `prompt_templates/`, `structure_types/`) → `sys._MEIPASS` 핸들링
|
||||
|
||||
배포 형태 (예정): `dist/scanvas_maker/` 폴더 통째로 zip → 약 150-200MB 압축. 사용자는
|
||||
.exe 더블클릭으로 실행, 본인 GCP 키 또는 Stability API 키만 환경변수/UI 입력.
|
||||
|
||||
---
|
||||
|
||||
## 14. 향후 로드맵
|
||||
|
||||
1. **다국어 (i18n)**: 한국어/영어 토글
|
||||
2. **추가 구조물 유형**: 배수문·교량·터널·송수관 등
|
||||
3. **Linux/macOS 지원**: 현재 Windows 전용
|
||||
4. **클라우드 렌더 큐**: 다수 DXF 일괄 처리 후 결과 한꺼번에 ZIP
|
||||
5. **VR/AR 출력**: glTF 익스포트 → Unity/Unreal 연동
|
||||
6. **변경 추적**: 같은 DXF 의 V1/V2 자동 비교 → 변경 영역 하이라이트
|
||||
|
||||
---
|
||||
|
||||
## 15. 핵심 메시지 (발표 마무리용)
|
||||
|
||||
> **S-CANVAS 는 토목·건설 도면을, "수일짜리 모델링 작업" 에서 "클릭 4번의 자동
|
||||
> 파이프라인" 으로 압축한다. 사실 기반(DEM·위성) 위에서 AI 가 사실감만 더하므로
|
||||
> 환각 위험 없이, 결정론적 재현성을 보장하면서, 발주처·주민설명회 발표용 조감도를
|
||||
> 분 단위로 양산할 수 있다. — Saman Corp.**
|
||||
|
||||
---
|
||||
|
||||
## 부록 A. 진입점 / 실행 방법
|
||||
|
||||
```bash
|
||||
python scanvas_maker.py
|
||||
```
|
||||
|
||||
진행 순서:
|
||||
1. `splash.py` 가 logo_intro.mp4 8초 재생 (페이드 인/아웃)
|
||||
2. `SCanvasApp(ctk.CTk)` 메인 창 기동
|
||||
3. (사이드바) DXF 파일 선택 → CRS 확인 → Step 1~4 순차 실행
|
||||
4. (옵션) 구조물 상세 3D 빌드 / 와이어프레임 / DEM 확장
|
||||
5. AI 렌더 결과는 `rendered_birdseye.png` (와 SQLite 이력)
|
||||
|
||||
## 부록 B. 디렉토리 구조
|
||||
|
||||
```
|
||||
D:\2026\00_EGVIEW2\
|
||||
├── scanvas_maker.py # 메인 진입점
|
||||
├── splash.py # 인트로 스플래시
|
||||
├── dem_extender.py
|
||||
├── geo_referencing.py
|
||||
├── structure_placement.py
|
||||
├── structure_templates.py
|
||||
├── intake_tower_parser.py + _3d_builder.py
|
||||
├── retaining_wall_parser.py + _3d_builder.py
|
||||
├── gate_parser.py + _3d_builder.py
|
||||
├── valve_chamber_parser.py + _3d_builder.py
|
||||
├── detail_parser.py
|
||||
├── filename_classifier.py
|
||||
├── polygon_reconstructor.py
|
||||
├── dxf_geometry.py
|
||||
├── tile_downloader.py
|
||||
├── view_detector.py
|
||||
├── view_reconstructor.py
|
||||
├── optional_detector.py
|
||||
├── gemini_renderer.py
|
||||
├── structure_vlm_feedback.py
|
||||
│
|
||||
├── harness/
|
||||
│ ├── __init__.py
|
||||
│ ├── seed_manager.py
|
||||
│ ├── quality_validator.py
|
||||
│ ├── prompt_registry.py
|
||||
│ └── logger.py
|
||||
│
|
||||
├── prompt_templates/
|
||||
│ └── prompt_v1.yaml
|
||||
│
|
||||
├── structure_types/
|
||||
│ └── structure_v1.yaml
|
||||
│
|
||||
├── Design/
|
||||
│ ├── Logo.png
|
||||
│ ├── logo_V2.png (현재 사용)
|
||||
│ ├── SAMAN_CI.gif
|
||||
│ ├── logo_intro.mp4 (8s, 24fps, 1280×720)
|
||||
│ ├── homepage_sample.png
|
||||
│ └── page_sample.png
|
||||
│
|
||||
├── cache/dem/ (런타임 DEM 캐시)
|
||||
├── scanvas_jobs.db (SQLite 이력)
|
||||
├── scanvas_harness.log (structlog)
|
||||
├── scanvas_diagnostic.log (Step 1 진단)
|
||||
├── CHANGELOG.md (수정 이력 역순)
|
||||
└── Build_log.txt (사용자 원본 요청 로그)
|
||||
```
|
||||
|
||||
## 부록 C. 핵심 메트릭 (발표 슬라이드 숫자용)
|
||||
|
||||
- 메인 모듈: 6300+ LOC (`scanvas_maker.py`)
|
||||
- 지원 구조물 유형: 4종 (취수탑·제수변실·옹벽·수문) + 확장 가능
|
||||
- 지원 좌표계: 5종 EPSG (한국 4 + Web Mercator 1)
|
||||
- 지원 위성 타일 서버: 9종 (Google·ArcGIS·Bing·OSM·OpenTopo·Vworld 3종 등)
|
||||
- 지원 AI 렌더 엔진: 3종 (Gemini Vertex·Gemini API·Stability)
|
||||
- DEM 자동 페치: AWS Terrarium 글로벌, ~30m 정확도
|
||||
- 인트로 스플래시: 8.0초 (24fps × 192 frames @ 1280×720)
|
||||
- 메시 통합 weld 톨러런스: 0.01m (1cm)
|
||||
- TIN 3-Zone 블렌드: smoothstep `3t² − 2t³`
|
||||
- 벽 컷 임계: slope_ratio > 4.0 (≈76°) AND z_span > 30m AND e_max > 5m
|
||||
|
||||
---
|
||||
|
||||
*본 문서는 NotebookLM 의 단일 소스로 사용 가능하도록 자기완결형으로 작성되었습니다.
|
||||
업로드 후 "Audio Overview", "마인드맵", "발표 슬라이드 초안" 자동 생성을 권장합니다.*
|
||||
1581540
SAMPLE_CAD/사연댐 전체계획 평면도_V6.dxf
Normal file
1581540
SAMPLE_CAD/사연댐 전체계획 평면도_V6.dxf
Normal file
File diff suppressed because it is too large
Load Diff
1446740
SAMPLE_CAD/수문_1.dxf
Normal file
1446740
SAMPLE_CAD/수문_1.dxf
Normal file
File diff suppressed because it is too large
Load Diff
356110
SAMPLE_CAD/수문_2.dxf
Normal file
356110
SAMPLE_CAD/수문_2.dxf
Normal file
File diff suppressed because it is too large
Load Diff
67
_build_icon.py
Normal file
67
_build_icon.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Pre-generate scanvas_S.ico from Design/logo_V2.png for PyInstaller spec.
|
||||
|
||||
Called by build.bat before PyInstaller runs. Standalone so encoding issues in
|
||||
.bat don't break it. Mirrors SCanvasApp._setup_window_icon logic.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from scipy import ndimage as nd
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = Path(__file__).resolve().parent
|
||||
src = root / "Design" / "logo_V2.png"
|
||||
dst_dir = root / "cache" / "icons"
|
||||
dst_dir.mkdir(parents=True, exist_ok=True)
|
||||
ico = dst_dir / "scanvas_S.ico"
|
||||
|
||||
if not src.exists():
|
||||
print(f"[icon] source not found: {src}")
|
||||
return 1
|
||||
|
||||
pil = Image.open(src).convert("RGBA")
|
||||
arr = np.asarray(pil).copy()
|
||||
v = np.maximum.reduce([arr[..., 0], arr[..., 1], arr[..., 2]])
|
||||
arr[..., 3] = np.where(v < 90, 0, arr[..., 3])
|
||||
|
||||
# Crop left ~32% to isolate the 'S' letter
|
||||
crop_w = min(arr.shape[1], 850)
|
||||
carr = arr[:, :crop_w, :]
|
||||
mask = carr[..., 3] > 100
|
||||
labeled, ncomp = nd.label(mask)
|
||||
if ncomp == 0:
|
||||
print("[icon] no foreground content found")
|
||||
return 1
|
||||
sizes = nd.sum(mask, labeled, range(1, ncomp + 1))
|
||||
max_size = float(sizes.max())
|
||||
keep = [i + 1 for i, s in enumerate(sizes) if s >= max_size * 0.1]
|
||||
keep_mask = np.isin(labeled, keep)
|
||||
cc = carr.copy()
|
||||
cc[..., 3] = np.where(keep_mask, carr[..., 3], 0)
|
||||
|
||||
ys, xs = np.where(keep_mask)
|
||||
cropped = Image.fromarray(
|
||||
cc[int(ys.min()):int(ys.max()) + 1, int(xs.min()):int(xs.max()) + 1, :],
|
||||
"RGBA",
|
||||
)
|
||||
cw, ch = cropped.size
|
||||
side = max(cw, ch)
|
||||
pad = max(int(side * 0.04), 4)
|
||||
sp = side + 2 * pad
|
||||
sq = Image.new("RGBA", (sp, sp), (0, 0, 0, 0))
|
||||
sq.paste(cropped, ((sp - cw) // 2, (sp - ch) // 2), cropped)
|
||||
sq.save(
|
||||
ico, format="ICO",
|
||||
sizes=[(16, 16), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)],
|
||||
)
|
||||
print(f"[icon] {ico} ({ico.stat().st_size} bytes)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
61
build.bat
Normal file
61
build.bat
Normal file
@@ -0,0 +1,61 @@
|
||||
@echo off
|
||||
REM ============================================================
|
||||
REM S-CANVAS distribution build (PyInstaller onedir)
|
||||
REM
|
||||
REM Run: build.bat
|
||||
REM
|
||||
REM Output: dist\S-CANVAS\S-CANVAS.exe (zip the folder to ship)
|
||||
REM ============================================================
|
||||
|
||||
setlocal enableextensions enabledelayedexpansion
|
||||
chcp 65001 >nul
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo.
|
||||
echo === [1/4] Check PyInstaller ===
|
||||
python -m PyInstaller --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo PyInstaller not installed -- installing now
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install pyinstaller
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] Failed to install PyInstaller. Try: pip install pyinstaller
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
python -m PyInstaller --version
|
||||
|
||||
echo.
|
||||
echo === [2/4] Generate window icon (scanvas_S.ico) ===
|
||||
python _build_icon.py
|
||||
if errorlevel 1 (
|
||||
echo [WARN] Icon generation failed -- continuing with default PyInstaller icon
|
||||
)
|
||||
|
||||
echo.
|
||||
echo === [3/4] Clean previous build artifacts ===
|
||||
if exist build rmdir /s /q build
|
||||
if exist "dist\S-CANVAS" rmdir /s /q "dist\S-CANVAS"
|
||||
|
||||
echo.
|
||||
echo === [4/4] Run PyInstaller (10-15 min) ===
|
||||
python -m PyInstaller --clean --noconfirm scanvas_maker.spec
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo [ERROR] PyInstaller build failed. See logs above.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo === DONE ===
|
||||
if exist "dist\S-CANVAS\S-CANVAS.exe" (
|
||||
echo Entry point: dist\S-CANVAS\S-CANVAS.exe
|
||||
echo Distribution: zip the dist\S-CANVAS\ folder
|
||||
echo User data: %%LOCALAPPDATA%%\S-CANVAS\
|
||||
echo db, logs, cache live here at runtime.
|
||||
) else (
|
||||
echo [WARN] dist\S-CANVAS\S-CANVAS.exe not found. Check build output.
|
||||
)
|
||||
|
||||
echo.
|
||||
endlocal
|
||||
877
dem_extender.py
Normal file
877
dem_extender.py
Normal 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
553
detail_parser.py
Normal 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
584
dxf_geometry.py
Normal 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 여수로 수문 설치도(1/2).dxf"),
|
||||
str(base / "12995740-M40-002 여수로 수문 설치도(2/2).dxf"),
|
||||
]
|
||||
|
||||
for p in paths:
|
||||
print(f"\n=== {Path(p).name} ===")
|
||||
r = extract_structural_geometry(p)
|
||||
print(f" 단위: {r.detected_unit} (scale={r.unit_scale})")
|
||||
print(f" 총 지오메트리: {len(r.shapes)}개")
|
||||
print(f" closed: {len(r.closed_shapes)}, open: {len(r.open_shapes)}")
|
||||
print(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
237
filename_classifier.py
Normal 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 여수로 수문 설치도(1/2).dxf",
|
||||
"12995740-M40-002 여수로 수문 설치도(2/2).dxf",
|
||||
"12996710-M40-001 신설 취수탑 설비 설치도(1/2).dxf",
|
||||
"12996710-M40-002 신설 취수탑 설비 설치도(2/2).dxf",
|
||||
"12996710-M43-002 신설 제수변실 설비 배치도.dxf",
|
||||
"1. 좌안옹벽 일반도 작성(2026.0109).dxf",
|
||||
"사연댐 전체계획 평면도.dxf",
|
||||
"A-Line 교량 상세도.dxf",
|
||||
"B-Road 터널 갱구 일반도.dxf",
|
||||
"관리사무소 평면도.dxf",
|
||||
"P-Station 펌프장 설치도.dxf",
|
||||
"random_file.dxf",
|
||||
]
|
||||
|
||||
print("파일명 기반 구조물 유형 추정:")
|
||||
print("=" * 70)
|
||||
for f in test_cases:
|
||||
tid, conf = suggest_with_confidence(f)
|
||||
if tid:
|
||||
print(f" [{tid:16}] ({conf:.0%}) {f}")
|
||||
else:
|
||||
print(f" [{'─' * 16}] (미매칭) {f}")
|
||||
743
gate_3d_builder.py
Normal file
743
gate_3d_builder.py
Normal 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 여수로 수문 설치도(1/2).dxf"
|
||||
f2 = base / "12995740-M40-002 여수로 수문 설치도(2/2).dxf"
|
||||
|
||||
params = parse_gate_dxf(str(f1), str(f2))
|
||||
print(params.summary())
|
||||
|
||||
builder = GateBuilder(params)
|
||||
meshes = builder.build_all()
|
||||
print(f"\nBuilt {len(meshes)} mesh components")
|
||||
for i, (m, c, o) in enumerate(meshes):
|
||||
print(f" [{i}] {m.n_points} pts, {m.n_cells} cells, color={c}, opacity={o}")
|
||||
1221
gate_parser.py
Normal file
1221
gate_parser.py
Normal file
File diff suppressed because it is too large
Load Diff
285
gemini_renderer.py
Normal file
285
gemini_renderer.py
Normal 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
1094
geo_referencing.py
Normal file
File diff suppressed because it is too large
Load Diff
0
harness/__init__.py
Normal file
0
harness/__init__.py
Normal file
137
harness/logger.py
Normal file
137
harness/logger.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""로거 - SQLite DB + structlog 기반 작업 이력 추적."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import Column, DateTime, Float, Integer, String, Text, create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
|
||||
# ──────────────────────────── ORM 모델 ────────────────────────────
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class JobRecord(Base):
|
||||
"""조감도 생성 작업 1건의 이력 레코드."""
|
||||
__tablename__ = "jobs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
dxf_path = Column(String(512), nullable=False)
|
||||
dxf_hash = Column(String(32))
|
||||
timestamp = Column(DateTime, default=datetime.utcnow)
|
||||
seed = Column(Integer)
|
||||
prompt_version = Column(String(32))
|
||||
prompt_hash = Column(String(32))
|
||||
status = Column(String(16), default="pending") # pending / running / done / failed
|
||||
output_path = Column(String(512))
|
||||
quality_score = Column(Float)
|
||||
error_message = Column(Text)
|
||||
latency_ms = Column(Float)
|
||||
|
||||
|
||||
# ──────────────────────────── DB 세션 ────────────────────────────
|
||||
|
||||
_engine = None
|
||||
_SessionFactory = None
|
||||
|
||||
|
||||
def init_db(db_path: str | Path = "cad_aerial_gen.db"):
|
||||
global _engine, _SessionFactory
|
||||
_engine = create_engine(f"sqlite:///{db_path}", echo=False)
|
||||
Base.metadata.create_all(_engine)
|
||||
_SessionFactory = sessionmaker(bind=_engine)
|
||||
|
||||
|
||||
def get_db_session() -> Session:
|
||||
if _SessionFactory is None:
|
||||
init_db()
|
||||
return _SessionFactory()
|
||||
|
||||
|
||||
# ──────────────────────────── structlog 설정 ────────────────────────────
|
||||
|
||||
def setup_logging(log_file: Optional[Path] = None, level: str = "INFO"):
|
||||
"""콘솔 + 파일 동시 로깅을 설정한다."""
|
||||
handlers = [logging.StreamHandler(sys.stdout)]
|
||||
if log_file:
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
handlers.append(logging.FileHandler(str(log_file), encoding="utf-8"))
|
||||
|
||||
logging.basicConfig(
|
||||
format="%(message)s",
|
||||
level=getattr(logging, level.upper(), logging.INFO),
|
||||
handlers=handlers,
|
||||
)
|
||||
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.processors.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S"),
|
||||
structlog.dev.ConsoleRenderer(),
|
||||
],
|
||||
wrapper_class=structlog.make_filtering_bound_logger(
|
||||
getattr(logging, level.upper(), logging.INFO)
|
||||
),
|
||||
logger_factory=structlog.PrintLoggerFactory(),
|
||||
)
|
||||
|
||||
|
||||
def get_logger(name: str = "cad_aerial_gen"):
|
||||
return structlog.get_logger(name)
|
||||
|
||||
|
||||
# ──────────────────────────── 작업 이력 헬퍼 ────────────────────────────
|
||||
|
||||
class JobLogger:
|
||||
"""작업 이력 CRUD 래퍼."""
|
||||
|
||||
def create_job(self, db: Session, dxf_path: str, dxf_hash: str = "") -> JobRecord:
|
||||
record = JobRecord(dxf_path=dxf_path, dxf_hash=dxf_hash, status="pending")
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
return record
|
||||
|
||||
def start_job(self, db: Session, job_id: int, seed: int, prompt_version: str, prompt_hash: str):
|
||||
record = db.query(JobRecord).filter_by(id=job_id).first()
|
||||
if record:
|
||||
record.status = "running"
|
||||
record.seed = seed
|
||||
record.prompt_version = prompt_version
|
||||
record.prompt_hash = prompt_hash
|
||||
db.commit()
|
||||
|
||||
def complete_job(
|
||||
self,
|
||||
db: Session,
|
||||
job_id: int,
|
||||
output_path: str,
|
||||
quality_score: float,
|
||||
latency_ms: float,
|
||||
):
|
||||
record = db.query(JobRecord).filter_by(id=job_id).first()
|
||||
if record:
|
||||
record.status = "done"
|
||||
record.output_path = output_path
|
||||
record.quality_score = quality_score
|
||||
record.latency_ms = latency_ms
|
||||
db.commit()
|
||||
|
||||
def fail_job(self, db: Session, job_id: int, error: str):
|
||||
record = db.query(JobRecord).filter_by(id=job_id).first()
|
||||
if record:
|
||||
record.status = "failed"
|
||||
record.error_message = error
|
||||
db.commit()
|
||||
|
||||
def list_jobs(self, db: Session, limit: int = 50):
|
||||
return db.query(JobRecord).order_by(JobRecord.id.desc()).limit(limit).all()
|
||||
58
harness/prompt_registry.py
Normal file
58
harness/prompt_registry.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""프롬프트 레지스트리 - 버전 관리 및 재현 가능성 보장."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
class PromptRegistry:
|
||||
"""프롬프트 템플릿 버전을 관리하고 변경 이력을 추적한다."""
|
||||
|
||||
def __init__(self, templates_dir: Path):
|
||||
self.templates_dir = templates_dir
|
||||
|
||||
def list_versions(self) -> List[str]:
|
||||
"""사용 가능한 템플릿 버전 목록을 반환한다 (최신순)."""
|
||||
yamls = sorted(self.templates_dir.glob("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
|
||||
98
harness/quality_validator.py
Normal file
98
harness/quality_validator.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""품질 검증기 - 생성된 이미지가 기준을 충족하는지 자동 검사한다."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
import cv2
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
raise ImportError("opencv-python, Pillow이 필요합니다: pip install opencv-python Pillow")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
passed: bool
|
||||
score: float # 0.0 ~ 1.0 종합 품질 점수
|
||||
resolution_ok: bool
|
||||
sharpness_ok: bool
|
||||
color_diversity_ok: bool
|
||||
messages: List[str]
|
||||
|
||||
@property
|
||||
def summary(self) -> str:
|
||||
status = "PASS" if self.passed else "FAIL"
|
||||
return f"[{status}] score={self.score:.2f} | " + " | ".join(self.messages)
|
||||
|
||||
|
||||
class QualityValidator:
|
||||
"""이미지 품질을 자동으로 검증한다."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
min_resolution: int = 2048,
|
||||
sharpness_threshold: float = 100.0,
|
||||
color_diversity_threshold: float = 0.15,
|
||||
):
|
||||
self.min_resolution = min_resolution
|
||||
self.sharpness_threshold = sharpness_threshold
|
||||
self.color_diversity_threshold = color_diversity_threshold
|
||||
|
||||
def validate(self, image_path: Path) -> ValidationResult:
|
||||
if not image_path.exists():
|
||||
return ValidationResult(
|
||||
passed=False, score=0.0,
|
||||
resolution_ok=False, sharpness_ok=False, color_diversity_ok=False,
|
||||
messages=["파일이 존재하지 않음"],
|
||||
)
|
||||
|
||||
img_pil = Image.open(image_path).convert("RGB")
|
||||
img_cv = cv2.cvtColor(np.array(img_pil), cv2.COLOR_RGB2BGR)
|
||||
|
||||
resolution_ok, res_msg = self._check_resolution(img_pil)
|
||||
sharpness_ok, sharp_score, sharp_msg = self._check_sharpness(img_cv)
|
||||
color_ok, color_msg = self._check_color_diversity(img_cv)
|
||||
|
||||
scores = [
|
||||
1.0 if resolution_ok else 0.0,
|
||||
min(sharp_score / (self.sharpness_threshold * 3), 1.0),
|
||||
1.0 if color_ok else 0.3,
|
||||
]
|
||||
overall_score = float(np.mean(scores))
|
||||
passed = resolution_ok and sharpness_ok and color_ok
|
||||
|
||||
return ValidationResult(
|
||||
passed=passed,
|
||||
score=overall_score,
|
||||
resolution_ok=resolution_ok,
|
||||
sharpness_ok=sharpness_ok,
|
||||
color_diversity_ok=color_ok,
|
||||
messages=[res_msg, sharp_msg, color_msg],
|
||||
)
|
||||
|
||||
def _check_resolution(self, img: Image.Image):
|
||||
w, h = img.size
|
||||
ok = w >= self.min_resolution and h >= self.min_resolution
|
||||
msg = f"해상도={w}x{h} {'OK' if ok else f'(최소 {self.min_resolution} 필요)'}"
|
||||
return ok, msg
|
||||
|
||||
def _check_sharpness(self, img_cv):
|
||||
gray = cv2.cvtColor(img_cv, cv2.COLOR_BGR2GRAY)
|
||||
lap_var = cv2.Laplacian(gray, cv2.CV_64F).var()
|
||||
ok = lap_var >= self.sharpness_threshold
|
||||
msg = f"선명도={lap_var:.1f} {'OK' if ok else f'(임계값 {self.sharpness_threshold})'}"
|
||||
return ok, float(lap_var), msg
|
||||
|
||||
def _check_color_diversity(self, img_cv):
|
||||
"""색상 다양성 검사 - 단색 평면 출력을 탐지한다."""
|
||||
hsv = cv2.cvtColor(img_cv, cv2.COLOR_BGR2HSV)
|
||||
s_channel = hsv[:, :, 1].astype(np.float32) / 255.0
|
||||
mean_saturation = float(s_channel.mean())
|
||||
ok = mean_saturation >= self.color_diversity_threshold
|
||||
msg = f"색상다양성={mean_saturation:.3f} {'OK' if ok else f'(임계값 {self.color_diversity_threshold})'}"
|
||||
return ok, msg
|
||||
66
harness/seed_manager.py
Normal file
66
harness/seed_manager.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Seed 관리자 - 작업별 Seed 고정 및 추적."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import random
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from harness.logger import JobRecord, get_db_session
|
||||
|
||||
|
||||
class SeedManager:
|
||||
"""DXF 파일 해시 기반 결정론적 seed를 생성하고 이력을 관리한다."""
|
||||
|
||||
MAX_SEED = 2**32 - 1
|
||||
|
||||
def get_seed(
|
||||
self,
|
||||
file_hash: str,
|
||||
fixed_seed: Optional[int] = None,
|
||||
deterministic: bool = True,
|
||||
) -> int:
|
||||
"""
|
||||
Args:
|
||||
file_hash: DXF 파일의 SHA256 해시 앞 16자
|
||||
fixed_seed: 사용자가 직접 지정한 seed (None이면 자동)
|
||||
deterministic: True면 파일 해시 기반, False면 랜덤
|
||||
"""
|
||||
if fixed_seed is not None:
|
||||
return int(fixed_seed) % (self.MAX_SEED + 1)
|
||||
|
||||
if deterministic:
|
||||
return self._hash_to_seed(file_hash)
|
||||
|
||||
return random.randint(0, self.MAX_SEED)
|
||||
|
||||
def get_or_create_seed(
|
||||
self,
|
||||
db: Session,
|
||||
job_id: int,
|
||||
file_hash: str,
|
||||
fixed_seed: Optional[int] = None,
|
||||
deterministic: bool = True,
|
||||
) -> int:
|
||||
"""DB에서 기존 seed를 조회하거나 새로 생성한다."""
|
||||
existing = db.query(JobRecord).filter_by(id=job_id).first()
|
||||
if existing and existing.seed is not None:
|
||||
return existing.seed
|
||||
|
||||
seed = self.get_seed(file_hash, fixed_seed, deterministic)
|
||||
if existing:
|
||||
existing.seed = seed
|
||||
db.commit()
|
||||
return seed
|
||||
|
||||
@staticmethod
|
||||
def _hash_to_seed(file_hash: str) -> int:
|
||||
"""파일 해시를 정수 seed로 변환한다."""
|
||||
digest = hashlib.sha256(file_hash.encode()).digest()
|
||||
return int.from_bytes(digest[:4], "big")
|
||||
|
||||
@staticmethod
|
||||
def describe(seed: int) -> str:
|
||||
return f"seed={seed} (0x{seed:08X})"
|
||||
463
intake_tower_3d_builder.py
Normal file
463
intake_tower_3d_builder.py
Normal 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 신설 취수탑 설비 설치도(1/2).dxf",
|
||||
"SAMPLE_CAD/12996710-M40-002 신설 취수탑 설비 설치도(2/2).dxf",
|
||||
]
|
||||
|
||||
params = parse_intake_tower(paths)
|
||||
print(params.summary())
|
||||
|
||||
builder = IntakeTowerBuilder(params)
|
||||
meshes = builder.build_all()
|
||||
print(f"\n{len(meshes)}개 구성요소 생성:")
|
||||
for i, (m, c, o) in enumerate(meshes):
|
||||
print(f" [{i:2}] {m.n_points:5}pts {m.n_cells:5}cells color={c}")
|
||||
361
intake_tower_parser.py
Normal file
361
intake_tower_parser.py
Normal 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 신설 취수탑 설비 설치도(1/2).dxf",
|
||||
"SAMPLE_CAD/12996710-M40-002 신설 취수탑 설비 설치도(2/2).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
172
optional_detector.py
Normal 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
227
polygon_reconstructor.py
Normal 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}")
|
||||
59
prompt_templates/prompt_v1.yaml
Normal file
59
prompt_templates/prompt_v1.yaml
Normal file
@@ -0,0 +1,59 @@
|
||||
version: "v1"
|
||||
description: "S-CANVAS 조감도 렌더링 프롬프트 v1 - 구조 보존 최적화"
|
||||
|
||||
# 시간대별 조명/하늘 묘사
|
||||
time_presets:
|
||||
daytime: "bright daylight, clear blue sky, sharp shadows, vivid green vegetation"
|
||||
sunset: "golden hour sunset, warm orange light, long dramatic shadows, glowing sky"
|
||||
night: "nighttime aerial view, moonlight reflections on water, city lights in distance, dark blue sky"
|
||||
dawn: "early dawn, soft pink and purple sky, morning mist over valleys, gentle light"
|
||||
overcast: "overcast sky, diffused soft light, muted colors, atmospheric fog"
|
||||
|
||||
# 카메라 앙각별 시점 묘사
|
||||
angle_presets:
|
||||
top_down: "top-down overhead aerial view, directly above looking straight down"
|
||||
high_angle: "high-angle bird's-eye view, slightly tilted perspective"
|
||||
oblique: "oblique aerial perspective, 3/4 view showing terrain depth"
|
||||
low_angle: "low-angle dramatic perspective, cinematic sweep"
|
||||
|
||||
# 앙각 → 프리셋 매핑 경계
|
||||
angle_thresholds:
|
||||
top_down: 70 # >= 70도
|
||||
high_angle: 45 # >= 45도
|
||||
oblique: 30 # >= 30도
|
||||
low_angle: 0 # < 30도
|
||||
|
||||
# 구조 보존 핵심 지시문 (항상 포함)
|
||||
structure_preservation:
|
||||
- "enhance the existing satellite terrain texture and details"
|
||||
- "maintain exact terrain shape, contours, and layout from the input image"
|
||||
- "preserve water bodies, roads, and structural positions precisely"
|
||||
- "do NOT add or remove any major landscape features"
|
||||
|
||||
# 품질 향상 지시문
|
||||
quality_enhancement:
|
||||
- "photorealistic architectural visualization"
|
||||
- "professional drone photography quality"
|
||||
- "8K ultra sharp detail, high dynamic range"
|
||||
- "realistic vegetation depth and canopy textures"
|
||||
- "natural water reflections and surface detail"
|
||||
|
||||
# 네거티브 프롬프트
|
||||
negative_prompt: >-
|
||||
blurry, low quality, distorted, watermark, text, logo,
|
||||
cartoon, anime, illustration, painting, sketch,
|
||||
oversaturated, underexposed, noisy, artifacts,
|
||||
completely different scene, unrelated content,
|
||||
changed terrain layout, moved structures, wrong topology
|
||||
|
||||
# Control Structure 파라미터
|
||||
control_params:
|
||||
control_strength: 0.85
|
||||
output_format: "png"
|
||||
|
||||
# img2img 파라미터
|
||||
img2img_params:
|
||||
model: "sd3.5-large"
|
||||
mode: "image-to-image"
|
||||
output_format: "png"
|
||||
# strength는 UI 슬라이더에서 동적으로 받음 (기본 0.4)
|
||||
48
requirements.txt
Normal file
48
requirements.txt
Normal file
@@ -0,0 +1,48 @@
|
||||
# S-CANVAS — runtime dependencies
|
||||
# Pinned to versions verified working on the build machine (2026-04).
|
||||
#
|
||||
# Install:
|
||||
# pip install -r requirements.txt
|
||||
#
|
||||
# Python: 3.9+ recommended (tested on 3.9.13). 3.11+ ideal for performance.
|
||||
# Platform: Windows 10/11. Linux/macOS likely works for Python deps but the GUI
|
||||
# (CustomTkinter, tkintermapview) and PyInstaller build are Windows-tuned.
|
||||
|
||||
# --- GUI ---
|
||||
customtkinter==5.2.2
|
||||
tkintermapview==1.29
|
||||
Pillow==11.3.0
|
||||
|
||||
# --- 3D / mesh ---
|
||||
pyvista==0.46.5
|
||||
# vtk is pulled in automatically by pyvista (~150MB)
|
||||
|
||||
# --- Geospatial / DXF ---
|
||||
ezdxf==1.4.2
|
||||
pyproj==3.6.1
|
||||
rasterio==1.4.3 # optional — for cache/dem/local.tif (NGII GeoTIFF). 미설치 시 AWS Terrarium 만 사용.
|
||||
|
||||
# --- Numerical ---
|
||||
numpy==2.0.2
|
||||
scipy==1.13.1
|
||||
matplotlib==3.9.4 # signed-distance polygon paths
|
||||
|
||||
# --- Image / video ---
|
||||
opencv-python==4.13.0.92 # splash MP4 + harness QualityValidator
|
||||
|
||||
# --- Network ---
|
||||
requests==2.32.5
|
||||
|
||||
# --- AI rendering (Gemini) ---
|
||||
google-genai==1.47.0
|
||||
# google-auth is pulled in by google-genai; pinned for reproducibility
|
||||
google-auth==2.49.2
|
||||
|
||||
# --- Persistence / logging ---
|
||||
SQLAlchemy==2.0.49
|
||||
structlog==25.5.0
|
||||
PyYAML==6.0.3
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# (선택) 배포용 .exe 빌드를 새 PC 에서 만들고 싶을 때만:
|
||||
# pip install pyinstaller==6.18.0
|
||||
134
resource_paths.py
Normal file
134
resource_paths.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""S-CANVAS 런타임 경로 해석.
|
||||
|
||||
PyInstaller 로 .exe 패키징 시:
|
||||
- **읽기 전용 자산**(Design/, prompt_templates/, structure_types/) 은
|
||||
`sys._MEIPASS` (onefile) 또는 .exe 옆 폴더(onedir) 에서 읽음.
|
||||
- **쓰기 데이터**(scanvas_jobs.db, *.log, cache/) 는 사용자별 영구 폴더
|
||||
`%LOCALAPPDATA%\\S-CANVAS\\` 에 저장. 설치 폴더 쓰기 권한 불필요(Program
|
||||
Files 설치 OK).
|
||||
|
||||
개발 모드(소스 직접 실행) 에서는:
|
||||
- 자산: 소스 트리 옆 (`Path(__file__).parent / "Design"` 등)
|
||||
- 쓰기 데이터: 동일하게 `%LOCALAPPDATA%\\S-CANVAS\\` 사용. 개발 중에도 설치
|
||||
배포본과 같은 경로에 쓰므로 행동이 일관됨. 단, 개발 편의를 위해 환경변수
|
||||
`SCANVAS_DEV_LOCAL=1` 설정 시 소스 트리 옆에 쓴다(과거 호환).
|
||||
|
||||
환경변수 오버라이드:
|
||||
- `SCANVAS_USER_DATA` — 쓰기 데이터 루트를 임의 경로로 강제(테스트/CI 용)
|
||||
- `SCANVAS_DEV_LOCAL=1` — 쓰기 데이터를 소스 트리 옆에 (개발 모드)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ──────────────────────────── 자산(읽기) 경로 ────────────────────────────
|
||||
|
||||
def _bundled() -> bool:
|
||||
"""PyInstaller 번들 안인가?"""
|
||||
return getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS")
|
||||
|
||||
|
||||
def asset_root() -> Path:
|
||||
"""읽기 전용 자산(Design/, prompt_templates/ 등) 의 부모 디렉토리.
|
||||
|
||||
- PyInstaller onefile: `sys._MEIPASS` (임시 추출 폴더)
|
||||
- PyInstaller onedir: `sys._MEIPASS` 또는 .exe 옆
|
||||
- dev: `Path(__file__).resolve().parent`
|
||||
"""
|
||||
if _bundled():
|
||||
return Path(sys._MEIPASS) # type: ignore[attr-defined]
|
||||
return Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def resource_path(*parts: str) -> Path:
|
||||
"""asset_root 아래 상대경로 해석. e.g. resource_path("Design", "Logo.png")."""
|
||||
return asset_root().joinpath(*parts)
|
||||
|
||||
|
||||
# ──────────────────────────── 쓰기 데이터 경로 ────────────────────────────
|
||||
|
||||
def _windows_localappdata() -> Path:
|
||||
base = os.environ.get("LOCALAPPDATA")
|
||||
if base:
|
||||
return Path(base)
|
||||
# XDG-ish 폴백
|
||||
home = Path.home()
|
||||
return home / "AppData" / "Local"
|
||||
|
||||
|
||||
def user_data_dir() -> Path:
|
||||
"""사용자별 영구 쓰기 폴더. 없으면 생성.
|
||||
|
||||
우선순위:
|
||||
1. `SCANVAS_USER_DATA` 환경변수
|
||||
2. `SCANVAS_DEV_LOCAL=1` 인 경우 → 소스 트리 옆 (`./` 아래)
|
||||
3. Windows: `%LOCALAPPDATA%\\S-CANVAS\\`
|
||||
4. 그 외 OS: `~/.scanvas/`
|
||||
"""
|
||||
override = os.environ.get("SCANVAS_USER_DATA")
|
||||
if override:
|
||||
p = Path(override)
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
if os.environ.get("SCANVAS_DEV_LOCAL") == "1" and not _bundled():
|
||||
p = Path(__file__).resolve().parent
|
||||
# 소스 트리에는 cache/ 가 이미 있으니 그대로 사용
|
||||
return p
|
||||
|
||||
if sys.platform == "win32":
|
||||
p = _windows_localappdata() / "S-CANVAS"
|
||||
elif sys.platform == "darwin":
|
||||
p = Path.home() / "Library" / "Application Support" / "S-CANVAS"
|
||||
else:
|
||||
p = Path.home() / ".scanvas"
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def db_path() -> Path:
|
||||
"""`scanvas_jobs.db` 절대경로 (생성 위치)."""
|
||||
return user_data_dir() / "scanvas_jobs.db"
|
||||
|
||||
|
||||
def log_path(name: str) -> Path:
|
||||
"""`scanvas_*.log` 절대경로. e.g. log_path('harness') → .../scanvas_harness.log."""
|
||||
return user_data_dir() / f"scanvas_{name}.log"
|
||||
|
||||
|
||||
def cache_dir(*sub: str) -> Path:
|
||||
"""`cache/` 하위 폴더. 없으면 생성. e.g. cache_dir('dem'), cache_dir('icons')."""
|
||||
p = user_data_dir() / "cache"
|
||||
if sub:
|
||||
p = p.joinpath(*sub)
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def diagnostic_log_path() -> Path:
|
||||
return log_path("diagnostic")
|
||||
|
||||
|
||||
def harness_log_path() -> Path:
|
||||
return log_path("harness")
|
||||
|
||||
|
||||
# ──────────────────────────── 디버그 헬퍼 ────────────────────────────
|
||||
|
||||
def describe() -> str:
|
||||
"""현재 경로 설정을 한 문자열로 요약 (런타임 진단용)."""
|
||||
return (
|
||||
f"asset_root={asset_root()} (bundled={_bundled()})\n"
|
||||
f"user_data_dir={user_data_dir()}\n"
|
||||
f"db={db_path()}\n"
|
||||
f"harness_log={harness_log_path()}\n"
|
||||
f"diagnostic_log={diagnostic_log_path()}\n"
|
||||
f"cache_root={cache_dir()}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(describe())
|
||||
379
retaining_wall_3d_builder.py
Normal file
379
retaining_wall_3d_builder.py
Normal 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
229
retaining_wall_parser.py
Normal 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
6569
scanvas_maker.py
Normal file
File diff suppressed because it is too large
Load Diff
137
scanvas_maker.spec
Normal file
137
scanvas_maker.spec
Normal file
@@ -0,0 +1,137 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
"""S-CANVAS PyInstaller spec — onedir 배포 빌드 설정.
|
||||
|
||||
빌드:
|
||||
pyinstaller --clean scanvas_maker.spec
|
||||
|
||||
결과:
|
||||
dist/S-CANVAS/ ← 통째로 zip 해서 배포
|
||||
S-CANVAS.exe ← 더블클릭 진입점
|
||||
Design/, prompt_templates/, structure_types/
|
||||
_internal/ ← Python 런타임 + 의존 라이브러리
|
||||
...
|
||||
|
||||
런타임 데이터(DB·로그·캐시):
|
||||
%LOCALAPPDATA%\\S-CANVAS\\ ← 사용자별 분리, 설치 폴더 쓰기 권한 불필요
|
||||
"""
|
||||
from PyInstaller.utils.hooks import collect_all, collect_submodules
|
||||
from pathlib import Path
|
||||
|
||||
block_cipher = None
|
||||
PROJECT_DIR = Path(SPECPATH).resolve()
|
||||
|
||||
|
||||
# ────────────────────── 자산 번들링 ──────────────────────
|
||||
# (소스경로, 번들 내 상대경로) — 런타임에서 sys._MEIPASS 아래에 같은 트리로 추출됨.
|
||||
datas = [
|
||||
(str(PROJECT_DIR / "Design"), "Design"),
|
||||
(str(PROJECT_DIR / "prompt_templates"), "prompt_templates"),
|
||||
(str(PROJECT_DIR / "structure_types"), "structure_types"),
|
||||
]
|
||||
|
||||
# ────────────────────── 거대 패키지 collect_all ──────────────────────
|
||||
# pyvista/vtk: PyInstaller 자동 감지 부분이 거나 → 명시적 collect_all 로 누락 방지.
|
||||
# pyproj: PROJ 데이터(proj.db) 자동 누락 빈번 → collect_all 로 datas/binaries 모두 집어 옴.
|
||||
binaries = []
|
||||
hiddenimports = []
|
||||
for pkg in [
|
||||
"pyvista",
|
||||
"vtkmodules",
|
||||
"pyproj",
|
||||
"ezdxf",
|
||||
"tkintermapview",
|
||||
"structlog",
|
||||
"customtkinter",
|
||||
"PIL",
|
||||
]:
|
||||
try:
|
||||
_d, _b, _h = collect_all(pkg)
|
||||
datas += _d
|
||||
binaries += _b
|
||||
hiddenimports += _h
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# google-genai 의 서브모듈은 collect_all 로 충분히 커버되지 않을 때가 있어 별도 추가
|
||||
hiddenimports += collect_submodules("google.genai")
|
||||
hiddenimports += [
|
||||
"sqlalchemy.dialects.sqlite",
|
||||
"sqlalchemy.dialects.sqlite.pysqlite",
|
||||
"sqlalchemy.ext.declarative",
|
||||
"scipy.spatial.transform._rotation_groups",
|
||||
"scipy.special.cython_special",
|
||||
"encodings.idna",
|
||||
]
|
||||
|
||||
# ────────────────────── 분석 단계 ──────────────────────
|
||||
a = Analysis(
|
||||
["scanvas_maker.py"],
|
||||
pathex=[str(PROJECT_DIR)],
|
||||
binaries=binaries,
|
||||
datas=datas,
|
||||
hiddenimports=hiddenimports,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
# 번들 크기 절감: 불필요한 거대 패키지 제외
|
||||
excludes=[
|
||||
"pytest",
|
||||
"IPython",
|
||||
"jupyter",
|
||||
"notebook",
|
||||
"tornado",
|
||||
"zmq",
|
||||
"matplotlib.tests",
|
||||
"numpy.tests",
|
||||
"scipy.tests",
|
||||
"pyvista.examples", # 거대 샘플 데이터 제외
|
||||
"vtkmodules.test",
|
||||
],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False,
|
||||
)
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
|
||||
# ────────────────────── 실행파일 ──────────────────────
|
||||
# 아이콘: 빌드 전 build.bat 가 cache/icons/scanvas_S.ico 를 생성. 없으면 None 폴백.
|
||||
_icon_path = PROJECT_DIR / "cache" / "icons" / "scanvas_S.ico"
|
||||
_exe_icon = str(_icon_path) if _icon_path.exists() else None
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name="S-CANVAS",
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True, # UPX 가 PATH 에 있으면 압축. 없어도 빌드 진행.
|
||||
upx_exclude=[
|
||||
"vcruntime140.dll",
|
||||
"python311.dll",
|
||||
"python312.dll",
|
||||
"VCRUNTIME140.dll",
|
||||
],
|
||||
console=False, # GUI 앱 — 콘솔 창 안 띄움
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon=_exe_icon,
|
||||
)
|
||||
|
||||
# ────────────────────── onedir 패키지 ──────────────────────
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
name="S-CANVAS",
|
||||
)
|
||||
187
splash.py
Normal file
187
splash.py
Normal 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
507
structure_placement.py
Normal 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
1583
structure_templates.py
Normal file
File diff suppressed because it is too large
Load Diff
168
structure_types/structure_v1.yaml
Normal file
168
structure_types/structure_v1.yaml
Normal file
@@ -0,0 +1,168 @@
|
||||
version: "v1"
|
||||
description: "S-CANVAS 구조물 유형 레지스트리 - 댐/도로/단지/하천 범용"
|
||||
|
||||
types:
|
||||
terrain:
|
||||
name_ko: "지형 (등고선)"
|
||||
render_mode: "tin"
|
||||
color: "#8B7355"
|
||||
description: "Z값이 있는 등고선/지형 데이터 → TIN 생성에 사용"
|
||||
z_source: "entity"
|
||||
|
||||
excavation:
|
||||
name_ko: "굴착"
|
||||
render_mode: "surface_overlay"
|
||||
color: "#D4A373"
|
||||
z_offset: -2.0
|
||||
opacity: 0.7
|
||||
description: "굴착 계획 영역 (폐합 폴리라인 → 지형 아래 면)"
|
||||
|
||||
embankment:
|
||||
name_ko: "성토/제체"
|
||||
render_mode: "surface_overlay"
|
||||
color: "#A0522D"
|
||||
z_offset: 3.0
|
||||
opacity: 0.7
|
||||
description: "성토/댐 제체 영역 (폐합 → 지형 위 면)"
|
||||
|
||||
road:
|
||||
name_ko: "도로/공사용도로"
|
||||
render_mode: "path_extrude"
|
||||
color: "#3D3D3D"
|
||||
width: 6.0
|
||||
z_offset: -0.3
|
||||
slope_ratio: 1.5
|
||||
description: "도로 중심선 → 지형을 깎아 평탄화 + 양쪽 굴착사면 생성 (1:1.5)"
|
||||
|
||||
cofferdam_upstream:
|
||||
name_ko: "상류 가물막이"
|
||||
render_mode: "wall_extrude"
|
||||
color: "#6C757D"
|
||||
height: 8.0
|
||||
thickness: 2.0
|
||||
description: "유수전환 상류 가물막이 (폐합/선형 → 벽체)"
|
||||
|
||||
cofferdam_downstream:
|
||||
name_ko: "하류 가물막이"
|
||||
render_mode: "wall_extrude"
|
||||
color: "#8D9CA6"
|
||||
height: 6.0
|
||||
thickness: 2.0
|
||||
description: "유수전환 하류 가물막이"
|
||||
|
||||
diversion:
|
||||
name_ko: "유수전환 수로"
|
||||
render_mode: "path_extrude"
|
||||
color: "#4A90D9"
|
||||
width: 8.0
|
||||
z_offset: -3.0
|
||||
description: "유수전환 터널/수로 중심선"
|
||||
|
||||
spillway:
|
||||
name_ko: "여수로 (수로)"
|
||||
render_mode: "path_extrude"
|
||||
color: "#2E86C1"
|
||||
width: 12.0
|
||||
z_offset: -1.0
|
||||
description: "여수로 수로 중심선 (수문 아님 — 수문은 spillway_gate)"
|
||||
|
||||
spillway_gate:
|
||||
name_ko: "수문 (여수로 게이트)"
|
||||
render_mode: "box_extrude"
|
||||
color: "#34495E"
|
||||
height: 8.0
|
||||
description: "여수로 수문 — 평면도에서는 위치만 잡고 상세도면으로 3D 빌드"
|
||||
|
||||
intake_tower:
|
||||
name_ko: "취수탑"
|
||||
render_mode: "box_extrude"
|
||||
color: "#566573"
|
||||
height: 25.0
|
||||
description: "취수탑 — 평면도에서는 위치만 잡고 상세도면으로 3D 빌드"
|
||||
|
||||
valve_chamber:
|
||||
name_ko: "제수변실/밸브실"
|
||||
render_mode: "box_extrude"
|
||||
color: "#707B7C"
|
||||
height: 3.5
|
||||
description: "제수변실/밸브실 — 평면도에서는 위치만 잡고 상세도면으로 3D 빌드"
|
||||
|
||||
building:
|
||||
name_ko: "건축물/가설건물"
|
||||
render_mode: "box_extrude"
|
||||
color: "#BDC3C7"
|
||||
height: 5.0
|
||||
description: "건축물 평면 (폐합 → 박스 extrude)"
|
||||
|
||||
temp_facility:
|
||||
name_ko: "가설부지/야적장"
|
||||
render_mode: "surface_overlay"
|
||||
color: "#E8DACC"
|
||||
z_offset: 0.5
|
||||
opacity: 0.6
|
||||
description: "임시 시설 부지 영역"
|
||||
|
||||
bridge:
|
||||
name_ko: "교량"
|
||||
render_mode: "elevated_path"
|
||||
color: "#95A5A6"
|
||||
width: 10.0
|
||||
height: 8.0
|
||||
description: "교량 (선형 → 공중 경로)"
|
||||
|
||||
tunnel:
|
||||
name_ko: "터널"
|
||||
render_mode: "path_extrude"
|
||||
color: "#555555"
|
||||
width: 10.0
|
||||
z_offset: -15.0
|
||||
description: "터널 (선형 → 지하 경로)"
|
||||
|
||||
retaining_wall:
|
||||
name_ko: "옹벽"
|
||||
render_mode: "wall_extrude"
|
||||
color: "#7F8C8D"
|
||||
height: 4.0
|
||||
thickness: 0.5
|
||||
description: "옹벽 (선형 → 수직 벽체)"
|
||||
|
||||
revetment:
|
||||
name_ko: "호안"
|
||||
render_mode: "surface_overlay"
|
||||
color: "#B8B8B0"
|
||||
z_offset: 0.0
|
||||
opacity: 0.5
|
||||
description: "하천 호안 영역"
|
||||
|
||||
pipeline:
|
||||
name_ko: "관로"
|
||||
render_mode: "tube_path"
|
||||
color: "#1ABC9C"
|
||||
diameter: 1.5
|
||||
z_offset: -2.0
|
||||
description: "관로/파이프라인 중심선"
|
||||
|
||||
boundary:
|
||||
name_ko: "경계선 (참고용)"
|
||||
render_mode: "line_only"
|
||||
color: "#E74C3C"
|
||||
line_width: 2.0
|
||||
description: "부지 경계, 사업 범위 등 참고 표시선"
|
||||
|
||||
ignore:
|
||||
name_ko: "무시 (사용 안 함)"
|
||||
render_mode: "none"
|
||||
color: "#CCCCCC"
|
||||
description: "이 레이어의 요소는 무시"
|
||||
|
||||
# 렌더 모드 설명
|
||||
render_modes:
|
||||
tin: "Z값 있는 점 → TIN 표면 생성"
|
||||
surface_overlay: "폐합 폴리라인 → TIN 위에 면 오버레이"
|
||||
path_extrude: "선형 → 폭/오프셋으로 도로/수로 면 생성"
|
||||
wall_extrude: "선형/폐합 → 수직 벽체 생성"
|
||||
box_extrude: "폐합 → 일정 높이 박스 생성"
|
||||
elevated_path: "선형 → 공중 경로 (교량)"
|
||||
tube_path: "선형 → 원형 단면 튜브"
|
||||
line_only: "선만 표시 (3D 면 없음)"
|
||||
none: "렌더링 안 함"
|
||||
500
structure_vlm_feedback.py
Normal file
500
structure_vlm_feedback.py
Normal 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
147
tile_downloader.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""XYZ 타일 서버에서 BBOX 영역 타일을 다운로드해 하나의 이미지로 합성.
|
||||
|
||||
지원:
|
||||
- 일반 XYZ 템플릿 (`{x}/{y}/{z}`, 선택적 `{s}` 서브도메인)
|
||||
- 줌 자동 하향 조정 (타일 수 상한 400)
|
||||
- BBOX에 맞게 크롭 (타일 경계 ≠ 실제 BBOX) + 최종 resize
|
||||
|
||||
사용 예:
|
||||
from tile_downloader import download_xyz_tiles
|
||||
img = download_xyz_tiles(
|
||||
"https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
37.5, 129.0, 37.6, 129.1,
|
||||
zoom=17, log_fn=print,
|
||||
)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import math
|
||||
import random
|
||||
from 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
408
valve_chamber_3d_builder.py
Normal 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
689
valve_chamber_parser.py
Normal 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
520
view_detector.py
Normal 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
921
view_reconstructor.py
Normal 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 여수로 수문 설치도(1/2).dxf", "spillway_gate"),
|
||||
("SAMPLE_CAD/12996710-M40-001 신설 취수탑 설비 설치도(1/2).dxf", "building"),
|
||||
("SAMPLE_CAD/12996710-M43-002 신설 제수변실 설비 배치도.dxf", "building"),
|
||||
]
|
||||
|
||||
for path, tid in samples:
|
||||
print(f"\n=== {Path(path).name} ({tid}) ===")
|
||||
try:
|
||||
views = detect_view_regions(path)
|
||||
info = diagnose_views(views, tid)
|
||||
print(f" 뷰 {info['total_views']}개 검출")
|
||||
print(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()
|
||||
Reference in New Issue
Block a user