260705_2
This commit is contained in:
+132
-28
@@ -1,44 +1,148 @@
|
||||
# 에이전트 행동 지침 및 라우팅 가이드 (agent.md)
|
||||
# 프로젝트 행동지침 및 기술 스택 명세 (agent.md)
|
||||
|
||||
## 1. 개발 환경 및 기술 스택 기준 (Technical Stack Baseline)
|
||||
에이전트는 코드를 작성, 수정 또는 검증할 때 환경 기준과 호환되도록 구현
|
||||
## 프로젝트 & DB 정보
|
||||
|
||||
### A. 백엔드 및 연산 엔진 (Python)
|
||||
### 📌 프로그램명
|
||||
**Aislo (아이슬로)**
|
||||
- **의미**: AI (인공지능) + Slotti (핀란드어: 임도/산림 도로)
|
||||
- **콘셉트**: 산림의 미래를 열어가는 인공지능 경로 설계 솔루션
|
||||
|
||||
---
|
||||
|
||||
## 1. 기술 환경 및 기본 스택 (Technical Stack Baseline)
|
||||
프로젝트의 코드를 작성, 테스트 또는 검증할 환경 명확화 및 호환 기준 제시.
|
||||
|
||||
### A. 백엔드 프레임워크 (Python)
|
||||
* **Runtime:** Python v3.12 or v3.13.7
|
||||
* **Framework:** FastAPI / Pydantic
|
||||
* **Geometry/GIS Engine:** Trimesh, Whitebox, Geopandas, Shapely, Rasterio, Laspy
|
||||
|
||||
### B. 데이터베이스 (PostgreSQL)
|
||||
* **DBMS:** PostgreSQL v17
|
||||
* **Spatial Extension:** PostGIS v3.4 (공간 쿼리 및 연산 활용)
|
||||
* **DB Driver:** 비동기 연동을 위한 `asyncpg` 기준 구동
|
||||
### B. 데이터베이스 (MariaDB)
|
||||
* **DBMS:** MariaDB v10.6+
|
||||
* **Character Set:** utf8mb4 (한글 완벽 지원)
|
||||
* **Collation:** utf8mb4_unicode_ci
|
||||
* **DB Driver (비동기):** `aiomysql` (순수 Python, Windows 호환. asyncmy는 Cython 빌드 필요로 미채택)
|
||||
* **쿼리 방식:** Raw SQL (ORM 사용 금지)
|
||||
* **공간 데이터:** JSON 기반 저장 (MariaDB는 PostGIS 미지원)
|
||||
|
||||
### C. 프론트엔드 및 인터페이스 (TypeScript & WebCAD)
|
||||
### C. 프론트엔드 & WebCAD (TypeScript & WebGL)
|
||||
* **Language/Runtime:** TypeScript / Node.js
|
||||
* **인터페이스:** HTML5 Canvas 및 WebGL 기반의 WebCAD 시스템 연동 표준 준수
|
||||
* **Rendering:** HTML5 Canvas 및 WebGL 기반 WebCAD 시스템 구현
|
||||
|
||||
### D. 데이터 및 파일 인코딩 표준
|
||||
* **인코딩:** 텍스트 파일은 UTF-8 표준을 강제 적용
|
||||
### D. 인코딩 및 다국어 표준
|
||||
* **인코딩:** 텍스트 파일은 UTF-8 표준으로 통일
|
||||
|
||||
---
|
||||
|
||||
## 2. 컨텍스트 라우팅 규칙 (Context Routing)
|
||||
명령 수신 시 답변 작성 및 코드 수정 전 다음 단계에 따라 정의서(MD) 필수 로드.
|
||||
## 2. 문맥 라우팅 및 문서 구조 (Context Routing)
|
||||
코드 작성 및 기술 검증 시 문서 우선순위에 따른 계층적 레퍼런스 필수 읽기.
|
||||
|
||||
* **1단계 (필수):** `.agent/structure.md` 즉시 로드 및 현재 디렉토리 구조 분석.
|
||||
* **2단계 (조건 분기):** 프롬프트 성격에 따라 필요한 파일 선별 로드.
|
||||
* **조건 A (UI, 제어, 컴포넌트, WebCAD):** `.agent/frontend.md` 필수 추가 로드.
|
||||
* **조건 B (연산 수식, 설계 기준, DB, PostGIS):** `.agent/backend.md` 필수 추가 로드.
|
||||
* **1단계 (필수):** `.agent/structure.md` 읽기 후 신규 폴더 생성 기준 분석.
|
||||
* **2단계 (선택 읽기):** 프로젝트 구현에 필요한 세부 기술 명세 읽기.
|
||||
* **그룹 A (UI, 스타일, 컴포넌트, WebCAD):** `.agent/frontend.md` 필수 추가 읽기.
|
||||
* **그룹 B (알고리즘, 저장, DB, MariaDB 값 제어):** `.agent/backend.md` 필수 추가 읽기.
|
||||
* **그룹 C (DB 구조 변경):** DB 구조 변경 시 `.agent/db_schema.md` 선택적 읽기.
|
||||
|
||||
---
|
||||
|
||||
## 3. 코드 작성 제약 조건 (Constraints)
|
||||
* **구조 준수:** `.agent/structure.md` 트리 구조 강제 준수.
|
||||
* **700줄 제한:** 단일 파일 코드 생성/수정 후 700줄 이상 시 리팩토링 필수 제안. 유저 승인 후 분할 및 `structure.md` 동시 업데이트.
|
||||
* **영역 격리:** 지정된 작업 영역 외 디렉토리 파일 접근·수정 시 유저 사전 승인 필수.
|
||||
* **정합성 검증:** 이전 맥락 단절을 고려하여 현재 소스 코드와 분할 정의서(`frontend.md`, `backend.md`) 간 정합성 최우선 검토.
|
||||
* **정보 요구 필수:** 연관 파일의 실제 내부 코드가 프롬프트에 제공되지 않은 경우, 임의 추측 코딩을 절대 금지하며 유저에게 파일 내용 제시를 먼저 요구할 것.
|
||||
* **스타일 통합 (자율 포매팅):** 에이전트는 코드 작성을 완료한 후, 반드시 프로젝트 터미널에서 다음 명령어를 직접 실행하여 서식을 정렬한 뒤 최종 저장해야 한다. (유저에게 서식 교정 작업을 전가하지 말 것)
|
||||
* *Python 파일 수정 시:* `ruff format [파일명]` 및 `ruff check --fix [파일명]` 실행
|
||||
* *TypeScript/CSS 파일 수정 시:* `npx prettier --write [파일명]` 실행
|
||||
* **의존성 준수:** 외부 라이브러리 활용 시 프로젝트에 명시된 호환 버전을 반드시 준수하며, 존재하지 않거나 단종된 함수를 유추하여 작성하는 것을 금지함.
|
||||
## 3. 코드 작성 기본 제약 (Constraints)
|
||||
* **구조 준수:** `.agent/structure.md` 트리 구조 엄격 준수.
|
||||
* **700줄 제한:** 단일 파일 코드 작성/수정 시 700줄 이상이 될 경우 사전 공지. 기능별 파일 분할 후 `structure.md` 갱신 필수.
|
||||
* **안전 보관:** 백엔드 작업 시 보안 저장소 경로 명시 및 임의 삭제 방지 필수.
|
||||
* **경로 활용:** 단계별 페이지 기반 폴더 구조(B03~B09)와 DB 경로 열의 기준은 `.agent/db_schema_simple.md`의 「파일시스템 경로와 DB 링크」를 따른다.
|
||||
* **일관성 검증:** 설계 단계에서부터 DB 설계, 파일 경로, API 라우팅이 모두 동일한 워크플로우 기준으로 통일되어야 한다. (불일치 발생 시 설계 단계에서 재논의 필수)
|
||||
* **코드 포맷팅 (자동화 도구):** 프로젝트의 코드 작성이 완료되는 시, 반드시 프로젝트 루트에서 각 언어별 포맷터를 재실행하여 스타일을 통일해야 한다.
|
||||
* *Python 포맷팅 명령어:* `ruff format [파일명]` 및 `ruff check --fix [파일명]` 실행
|
||||
* *TypeScript/CSS 포맷팅 명령어:* `npx prettier --write [파일명]` 실행
|
||||
* **외부 라이브러리 활용:** 외부 라이브러리 활용 시 프로젝트에 맞춰 호환 라이브러리 선정하고, 불필요한 함수는 작성하지 않도록 최소화.
|
||||
|
||||
---
|
||||
|
||||
## 4. 데이터베이스 상세 명세 (Database Specification)
|
||||
|
||||
### 4.1 스키마 기본 정보
|
||||
- **DB 명:** `aislo_db`
|
||||
- **DBMS:** MariaDB v10.6+
|
||||
- **인코딩:** utf8mb4_unicode_ci
|
||||
- **테이블 수:** 18개
|
||||
- **드라이버:** aiomysql (비동기)
|
||||
|
||||
### 4.2 파일 경로 추적 (Path Tracking)
|
||||
**모든 중간 산출물의 파일 경로를 DB에 기록:**
|
||||
- `input_files.raw_file_path` — 원본 입력 파일
|
||||
- `processed_point_cloud.converted_file_path` — 변환된 포인트클라우드
|
||||
- `surface_models.model_file_path` — 지표면 모델
|
||||
- `routes.route_data_path` — 경로 데이터
|
||||
- `longitudinal_sections.longitudinal_file_path` — 종단면
|
||||
- `cross_sections.cross_section_file_path` — 횡단면
|
||||
- `structures.structure_data_path` — 구조물 배치
|
||||
- `quantity_items.quantity_data_path` — 수량 항목
|
||||
- `outputs.outputs_directory_path` — 산출물 폴더
|
||||
- `output_files.output_file_path` — 개별 산출 파일
|
||||
|
||||
### 4.3 저장소 구조 (Workflow-based Folder Structure)
|
||||
```
|
||||
storage/{company_slug}/{user_slug}/{project_id}/
|
||||
├── B03_FileInput/input/ (WF0: 원본 입력)
|
||||
├── B04_wf1_Surface/processed/ (WF1: 변환된 포인트클라우드 & 모델)
|
||||
├── B05_wf2_Route/route/ (WF2: 경로 설계)
|
||||
├── B06_wf3_ProfileCross/ (WF3: 종단면 & 횡단면)
|
||||
├── B07_wf4_DesignDetail/structures/ (WF4: 구조물 배치)
|
||||
├── B08_wf5_Quantity/quantities/ (WF5: 수량 산출)
|
||||
└── B09_wf6_Estimation/v1,v2,.../ (WF6: 최종 산출물)
|
||||
```
|
||||
|
||||
### 4.4 공간 데이터 처리 (MariaDB 특성)
|
||||
- **지하형 기하 데이터:** GEOMETRY 타입 미지원 → JSON으로 저장
|
||||
- **좌표 저장 예:** `{"type": "Point", "coordinates": [127.5, 37.5]}`
|
||||
- **경로 저장 예:** `{"type": "LineString", "coordinates": [[127.5, 37.5], [127.6, 37.6]]}`
|
||||
- **애플리케이션 처리:** Python의 Shapely, Geopandas에서 JSON 파싱 후 기하 연산
|
||||
|
||||
---
|
||||
|
||||
## 5. 워크플로우 6단계 (6-Stage Workflow)
|
||||
|
||||
```
|
||||
WF0: B03_FileInput (파일 입력)
|
||||
↓ (LAS, TIF, TFW, PRJ, DXF 업로드)
|
||||
WF1: B04_wf1_Surface (지표면 분석)
|
||||
↓ (DEM, TIN, 포인트클라우드 변환)
|
||||
WF2: B05_wf2_Route (경로 설계)
|
||||
↓ (최적 경로 계산)
|
||||
WF3: B06_wf3_ProfileCross (종횡단 생성)
|
||||
↓ (종단면, 횡단면 생성)
|
||||
WF4: B07_wf4_DesignDetail (상세 설계)
|
||||
↓ (구조물 배치)
|
||||
WF5: B08_wf5_Quantity (수량 산출)
|
||||
↓ (수량 항목 계산)
|
||||
WF6: B09_wf6_Estimation (견적·문서)
|
||||
↓ (Excel, PDF, DXF 생성)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 주요 문서 참고 순서
|
||||
|
||||
1. **구조 설계:** `.agent/structure.md` 읽기
|
||||
2. **프론트 구현:** `.agent/frontend.md` 읽기
|
||||
3. **백엔드 구현:** `.agent/backend.md` 읽기
|
||||
4. **마이그레이션:** `.agent/migration_plan.md` 읽기
|
||||
5. **DB 스키마:** `.agent/db_schema_simple.md` 읽기
|
||||
6. **프로젝트 정보:** `.agent/project_info.md` 읽기
|
||||
|
||||
---
|
||||
|
||||
## 7. 요약
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| **프로그램명** | Aislo (아이슬로) |
|
||||
| **DB 명** | aislo_db |
|
||||
| **DBMS** | MariaDB v10.6+ |
|
||||
| **인코딩** | utf8mb4_unicode_ci |
|
||||
| **Backend** | Python 3.12+ / FastAPI |
|
||||
| **Frontend** | TypeScript / Node.js |
|
||||
| **드라이버** | aiomysql |
|
||||
| **워크플로우** | 6단계 (WF0~WF6) |
|
||||
| **테이블 수** | 18개 |
|
||||
| **폴더 구조** | 워크플로우 기반 (B03~B09) |
|
||||
|
||||
+22
-10
@@ -8,9 +8,12 @@
|
||||
|
||||
---
|
||||
|
||||
## 2. DB 및 PostGIS (Database)
|
||||
* **비동기 통신:** `async/await` 및 `asyncpg` 드라이버 사용 강제.
|
||||
* **공간 연산:** ORM 사용 금지. `ST_Volume`, `ST_Distance` 등 PostGIS 기반 **Raw SQL** 작성 원칙.
|
||||
## 2. DB 및 MariaDB (Database)
|
||||
* **비동기 통신:** `async/await` 및 `aiomysql` 드라이버 사용 강제. (asyncmy는 Cython 빌드 필요로 Windows 환경 미채택)
|
||||
* **쿼리 방식:** ORM 사용 금지. **Raw SQL** 작성 원칙. 파라미터 바인딩은 `%s` 플레이스홀더 사용.
|
||||
* **커넥션/트랜잭션:** 풀에서 `pool.acquire()`로 커넥션 확보 후 `connection.cursor()`로 커서 생성. 다건 쓰기는 `connection.begin()` → `commit()` / 예외 시 `rollback()`.
|
||||
* **자동 증가 ID:** `INSERT` 후 `cursor.lastrowid`로 조회 (PostgreSQL의 `RETURNING id` 미지원).
|
||||
* **공간 데이터:** GEOMETRY 타입 미지원. 좌표, 다각형, 경로 등은 JSON 형식으로 저장 후 애플리케이션에서 처리.
|
||||
|
||||
---
|
||||
|
||||
@@ -18,6 +21,14 @@
|
||||
* **하드코딩 금지:** 제어 변수/파라미터 하드코딩 금지. `config/config_system.py`에서 `import` 필수.
|
||||
* **물리 파일 격리:** 포인트 클라우드, 분석 중간 파일, 메쉬, 임시 커서는 DB 저장 금지.
|
||||
* **저장 경로:** `storage/[고객사명]/[사용자명]/[프로젝트ID]/`에 물리 파일 저장 후 DB에는 경로만 기록.
|
||||
* **단계별 루트 강제:** 프로젝트 저장소 내부는 실제 워크플로우 페이지명과 동일한 `B03_FileInput/` ~ `B09_wf6_Estimation/` 폴더로 분리한다. `raw/`, `processed/`, `computed/`를 프로젝트 루트의 공용 폴더로 만들지 않는다.
|
||||
* **경로 정의 우선순위:** 단계별 세부 폴더와 DB 경로 열의 기준은 `.agent/db_schema_simple.md`의 「파일시스템 경로와 DB 링크」를 따른다.
|
||||
* **경로 생성 책임:** 페이지별 백엔드는 자기 단계 폴더만 생성·수정한다. 다른 단계의 산출물을 직접 삭제하거나 덮어쓰지 않고 stale 상태를 통해 재계산 필요성을 전파한다.
|
||||
* **DB 저장 형식:** DB에는 프로젝트 루트 기준 상대 경로를 기록하고, 실제 파일 접근 시 설정의 저장소 루트와 안전하게 결합한다. 사용자 입력 경로를 직접 결합하거나 절대 경로를 DB에 저장하지 않는다.
|
||||
* **MariaDB 특성:**
|
||||
- 공간 기하 데이터(좌표, 폴리곤, 경로 등)는 GEOMETRY 타입 미지원 → JSON 또는 TEXT로 저장
|
||||
- 예: `{"type": "Point", "coordinates": [127.5, 37.5]}` (GeoJSON 형식)
|
||||
- 애플리케이션(Python)에서 JSON 파싱 후 기하 연산 처리
|
||||
|
||||
---
|
||||
|
||||
@@ -33,11 +44,12 @@
|
||||
### 5.1 다중 브라우저 동시 작업 원칙
|
||||
* **브라우저 독립성:** 같은 계정/프로젝트를 여러 브라우저에서 동시 접근 가능. 각 브라우저는 서버 데이터 기반 독립 작동.
|
||||
* **클라이언트 상태 격리:** `localStorage`는 브라우저별 격리(도메인 단위 공유). 공유 데이터는 항상 서버 영구저장소 우선.
|
||||
* **영구저장소 설계:** 단계별 계산 결과를 `storage/[고객사]/[사용자]/[프로젝트ID]/result_[단계].json` 형태로 물리 저장.
|
||||
* `result_scan.json` — 포인트 클라우드 필터링 결과
|
||||
* `result_surface.json` — 지표면 모델 선택
|
||||
* `result_route.json` — 경로 설계 결과
|
||||
* `result_section.json` — 종횡단 생성 결과
|
||||
* **영구저장소 설계:** 계산 결과는 `.agent/db_schema_simple.md`에 정의된 페이지별 단계 폴더에 저장한다.
|
||||
* `B03_FileInput/` — 원본 입력 및 파일 메타데이터
|
||||
* `B04_wf1_Surface/` — 변환 포인트클라우드, 지표면 모델 및 분석 결과
|
||||
* `B05_wf2_Route/` — 경로, 경로점 및 설계 파라미터
|
||||
* `B06_wf3_ProfileCross/` — 종단·횡단 결과 및 인덱스
|
||||
* **워크플로우 상태:** 여러 단계가 공유하는 `workflow.json`의 실제 위치는 저장소 경로 유틸에서 단일하게 정의하며, 원자적 쓰기를 적용한다. 단계별 결과 파일의 위치를 프로젝트 루트의 `result_*.json` 이름으로 추정하지 않는다.
|
||||
|
||||
### 5.2 Stale 상태 감지 및 전파
|
||||
* **workflow.json 구조:**
|
||||
@@ -56,7 +68,7 @@
|
||||
```python
|
||||
def _patch_workflow_stale(project_id: str, stale_from: str | None) -> None:
|
||||
"""workflow.json의 stale_from 필드만 원자적 업데이트"""
|
||||
wf_path = storage_path / project_id / "workflow.json"
|
||||
wf_path = get_project_workflow_path(project_id)
|
||||
# 기존 상태 유지하며 stale_from만 변경
|
||||
```
|
||||
|
||||
@@ -88,4 +100,4 @@
|
||||
"changed_by": "other_browser"
|
||||
}
|
||||
```
|
||||
* **마이그레이션 시기:** 사용자 수 증가 또는 실시간성 요구 시 적용
|
||||
* **마이그레이션 시기:** 사용자 수 증가 또는 실시간성 요구 시 적용
|
||||
|
||||
@@ -1,682 +0,0 @@
|
||||
# DB 구조 설계 제안서
|
||||
|
||||
**작성일:** 2026-07-05
|
||||
**대상:** 임도 설계 및 견적 자동화 웹앱 (프로젝트 기반 멀티테넌트)
|
||||
**기술 스택:** PostgreSQL v17 + PostGIS v3.4 / asyncpg / FastAPI
|
||||
|
||||
---
|
||||
|
||||
## 1. 설계 원칙
|
||||
|
||||
### 1.1 멀티테넌트 아키텍처
|
||||
- **프로젝트 단위 데이터 격리:** 각 사용자의 프로젝트는 별도의 논리적 네임스페이스로 관리
|
||||
- **하이브리드 저장소:**
|
||||
- **DB:** 사용자, 프로젝트 메타데이터, 설계 결과(경로, 단면, 수량) 저장
|
||||
- **파일시스템:** 원본 입력파일(LAS/TIF/DXF), 중간 산출물(mesh/타일), 최종 산출물(DXF/Excel/PDF) 저장
|
||||
- **스토리지 경로:** `storage/{회사명}/{사용자명}/{프로젝트ID}/` 하위로 자동 생성
|
||||
|
||||
### 1.2 데이터 계층 분리
|
||||
```
|
||||
[입력] LAS, TFW, PRJ, TIF → [분석] DEM/Mesh/필터 → [설계] 경로, 단면 → [산출] DXF, Excel
|
||||
(storage) (DB + storage) (DB + storage) (storage)
|
||||
```
|
||||
|
||||
### 1.3 공간 데이터 전용 설계
|
||||
- PostGIS 기하학적 타입 활용 (geometry, geography)
|
||||
- 좌표계 일관성: EPSG 코드 저장 후 필요 시 변환
|
||||
- 벡터 데이터(경로, 경계선) ↔ 래스터 샘플링 간 추적 가능하도록 설계
|
||||
|
||||
---
|
||||
|
||||
## 2. 핵심 Entity 및 관계
|
||||
|
||||
### 2.1 사용자 및 인증 (users, user_sessions)
|
||||
```
|
||||
users
|
||||
├── id (PK)
|
||||
├── email (UNIQUE)
|
||||
├── password_hash
|
||||
├── name
|
||||
├── company (FK → companies)
|
||||
├── role (admin, user, guest)
|
||||
├── created_at, updated_at
|
||||
|
||||
companies
|
||||
├── id (PK)
|
||||
├── name (UNIQUE)
|
||||
├── created_by (FK → users)
|
||||
├── members [] (users, M:N via user_company_roles)
|
||||
├── created_at, updated_at
|
||||
|
||||
user_sessions (토큰 저장 — optional, Redis 권장)
|
||||
├── id (PK)
|
||||
├── user_id (FK → users)
|
||||
├── token
|
||||
├── expires_at
|
||||
```
|
||||
|
||||
**설계 의도:**
|
||||
- 회사 단위로 프로젝트, 사용자를 그룹화
|
||||
- 회사별 권한 분리 (협업 확장성)
|
||||
- 향후 팀 공유 프로젝트 기능 추가 가능
|
||||
|
||||
### 2.2 프로젝트 메타데이터 (projects, project_versions)
|
||||
```
|
||||
projects
|
||||
├── id (PK, UUID)
|
||||
├── user_id (FK → users) — 소유자
|
||||
├── company_id (FK → companies)
|
||||
├── name
|
||||
├── region (지역명: 예 "울진군 금강송면")
|
||||
├── road_type (간선임도, 지선임도, 산불진화임도, 계류보전)
|
||||
├── project_year (사업 연도, INT)
|
||||
├── estimated_length_m (추정 연장)
|
||||
├── memo
|
||||
├── status (NEW, ANALYZING, WF1_COMPLETE, WF2_COMPLETE, ... , CONFIRMED, DONE)
|
||||
├── crs_epsg (좌표계, INT — 예: 5178)
|
||||
├── bbox (GEOMETRY(Polygon)) — 전체 프로젝트 범위
|
||||
├── created_at, updated_at
|
||||
├── deleted_at (soft delete)
|
||||
|
||||
project_versions (버전 관리 — 선택)
|
||||
├── id (PK)
|
||||
├── project_id (FK → projects)
|
||||
├── version_num
|
||||
├── snapshot_at (스냅샷 시점)
|
||||
├── status (저장된 상태)
|
||||
└── data (JSONB — 설계 데이터 스냅샷)
|
||||
```
|
||||
|
||||
**설계 의도:**
|
||||
- 프로젝트 생명주기 추적 (NEW → WF1 → WF2 → ... → CONFIRMED)
|
||||
- 좌표계 메타데이터 저장 (변환 오류 방지)
|
||||
- 버전 관리는 (선택) — 회원가입 초기엔 미필수, 협업 필요 시 추가
|
||||
|
||||
### 2.3 입력 파일 관리 (input_files)
|
||||
```
|
||||
input_files
|
||||
├── id (PK)
|
||||
├── project_id (FK → projects)
|
||||
├── file_type (las, tif, tfw, prj, dxf, dwg, other)
|
||||
├── original_filename
|
||||
├── stored_path (storage/{...}/raw/{file_type}/{filename})
|
||||
├── file_size_mb
|
||||
├── upload_by (FK → users)
|
||||
├── upload_at
|
||||
├── crs_epsg (파일이 가진 좌표계)
|
||||
├── metadata (JSONB — 해상도, 데이터 범위, 포인트 수 등)
|
||||
└── status (UPLOADED, PROCESSED, ARCHIVED)
|
||||
|
||||
point_cloud_metadata
|
||||
├── id (PK)
|
||||
├── input_file_id (FK → input_files, LAS만)
|
||||
├── point_count (INT)
|
||||
├── min_z, max_z, mean_z (높이)
|
||||
├── x_min, x_max, y_min, y_max (공간 범위)
|
||||
├── densitiy_per_sqm (밀도)
|
||||
└── classification_summary (JSONB — {ground: N, vegetation: N, building: N, ...})
|
||||
```
|
||||
|
||||
**설계 의도:**
|
||||
- 입력 파일의 생명주기 추적
|
||||
- 포인트클라우드 메타데이터로 전처리 여부 판단
|
||||
- storage 폴더와 DB 간 일관성 확보
|
||||
|
||||
### 2.4 지표면 모델 및 분석 결과 (surface_models, terrain_layers)
|
||||
```
|
||||
surface_models (WF1 출력)
|
||||
├── id (PK)
|
||||
├── project_id (FK → projects)
|
||||
├── model_type (dem_grid, tin, mesh_triangulated, contour_lines)
|
||||
├── source_file_id (FK → input_files, LAS)
|
||||
├── status (PROCESSING, COMPLETE, FAILED)
|
||||
├── crs_epsg
|
||||
├── resolution_m (래스터 경우, NULL if 벡터)
|
||||
├── stored_path (storage/{...}/processed/surface/)
|
||||
├── bounds (GEOMETRY(Polygon)) — 모델 범위
|
||||
├── metadata (JSONB — 필터링 파라미터, 생성 시각 등)
|
||||
├── created_at, completed_at
|
||||
|
||||
terrain_layers
|
||||
├── id (PK)
|
||||
├── surface_model_id (FK → surface_models)
|
||||
├── layer_name (지표, 제1층, 제2층 등 15종)
|
||||
├── geometry_type (POINTCLOUD, GRID, MESH, CONTOUR)
|
||||
├── stored_path (GeoJSON / GeoTIFF / LAS 등)
|
||||
└── statistics (JSONB — min_z, max_z, mean_slope, etc.)
|
||||
```
|
||||
|
||||
**설계 의도:**
|
||||
- WF1 분석 결과(DEM, TIN, mesh 등)의 출처와 메타데이터 추적
|
||||
- 층(layer)별로 렌더링 최적화 가능
|
||||
- 재분석 시 기존 결과와 비교 가능
|
||||
|
||||
### 2.5 경로 설계 (routes, route_points, route_statistics)
|
||||
```
|
||||
routes (WF2 출력)
|
||||
├── id (PK)
|
||||
├── project_id (FK → projects)
|
||||
├── status (DRAFT, CONFIRMED, ARCHIVED)
|
||||
├── start_point (GEOMETRY(Point)) — 지형 위 실제 좌표
|
||||
├── end_point (GEOMETRY(Point))
|
||||
├── start_chainage_m, end_chainage_m (측점)
|
||||
├── total_length_m
|
||||
├── grade_percent[] (JSONB array — 각 구간 종단 경사도)
|
||||
├── constraints (JSONB — max_grade, min_radius, avoidance_zones)
|
||||
├── algorithm_params (JSONB — 비용함수 가중치, 계산 시간 등)
|
||||
├── computed_at
|
||||
└── geometry (GEOMETRY(LineString, Z) — 3D 경로)
|
||||
|
||||
route_points (경로 포인트 샘플, 웹 렌더링용)
|
||||
├── id (PK)
|
||||
├── route_id (FK → routes)
|
||||
├── chainage_m (측점)
|
||||
├── geometry (GEOMETRY(Point, Z))
|
||||
├── elevation_m, slope_percent
|
||||
└── sequence_num
|
||||
|
||||
route_statistics
|
||||
├── id (PK)
|
||||
├── route_id (FK → routes)
|
||||
├── min_slope, max_slope, mean_slope
|
||||
├── cut_volume_m3, fill_volume_m3
|
||||
├── tree_cutting_volume (목재 추정)
|
||||
└── cost_score (알고리즘 점수)
|
||||
```
|
||||
|
||||
**설계 의도:**
|
||||
- 경로의 기하학적 정보(3D LineString) + 측점 기반 추적
|
||||
- 재계산 시 이전 결과와 버전 비교 가능
|
||||
- 사용자가 수정한 경로도 저장 가능 (draft ↔ confirmed)
|
||||
|
||||
### 2.6 종단면 및 횡단면 (longitudinal_sections, cross_sections)
|
||||
```
|
||||
longitudinal_sections
|
||||
├── id (PK)
|
||||
├── project_id (FK → projects)
|
||||
├── route_id (FK → routes)
|
||||
├── computed_at
|
||||
├── geometry (GEOMETRY(LineString, Z)) — 경로 따라가며 샘플링한 표고
|
||||
├── data (JSONB)
|
||||
│ ├── chainages [0, 20, 40, ...]
|
||||
│ ├── elevations [100.5, 102.3, ...]
|
||||
│ ├── grades [2.5, 1.8, ...]
|
||||
│ └── design_elevations [100.0, 102.0, ...] (설계 기준)
|
||||
└── stored_path (storage/{...}/sections/longitudinal.json)
|
||||
|
||||
cross_sections
|
||||
├── id (PK)
|
||||
├── project_id (FK → projects)
|
||||
├── route_id (FK → routes)
|
||||
├── chainage_m (측점)
|
||||
├── sequence_num (1st, 2nd, ...)
|
||||
├── geometry (GEOMETRY(LineString, Z)) — 지형 횡단면
|
||||
├── geometry_design (GEOMETRY(LineString, Z)) — 설계 횡단면
|
||||
├── data (JSONB)
|
||||
│ ├── left_slope, right_slope
|
||||
│ ├── width_m
|
||||
│ ├── cut_volume_m3, fill_volume_m3
|
||||
│ ├── structures [] (낙석방지책, 돌붙임 등)
|
||||
│ └── notes
|
||||
└── stored_path (storage/{...}/sections/cross_{chainage}.json)
|
||||
```
|
||||
|
||||
**설계 의도:**
|
||||
- 종단면: 경로 따라 세로 방향 표고 추적
|
||||
- 횡단면: 20m 간격 가로 방향 단면 + 설계 기준
|
||||
- JSONB로 유연한 추가 메타데이터 저장
|
||||
|
||||
### 2.7 설계 및 구조물 (design_details, structures, quantity_items)
|
||||
```
|
||||
structures (WF4 구조물 라이브러리)
|
||||
├── id (PK)
|
||||
├── project_id (FK → projects)
|
||||
├── cross_section_id (FK → cross_sections)
|
||||
├── structure_type (낙석방지책, 돌붙임, 계간수로, 낙차공, etc.)
|
||||
├── chainage_m, location (LEFT, CENTER, RIGHT)
|
||||
├── length_m, width_m, height_m (기본 치수)
|
||||
├── material (강재, 콘크리트, 목재, 돌 등)
|
||||
├── quantity (개수)
|
||||
├── unit_price (단가)
|
||||
├── geometry (GEOMETRY(Polygon)) — 3D 구조물 배치
|
||||
├── design_notes (JSONB)
|
||||
└── last_modified_by (FK → users)
|
||||
|
||||
quantity_items (WF5 수량 산출)
|
||||
├── id (PK)
|
||||
├── project_id (FK → projects)
|
||||
├── category (토공, 구조물, 포장, 배수, 녹화, 안전시설)
|
||||
├── item_name (예: "절토 일반", "낙석방지책 설치" 등)
|
||||
├── unit (m3, 개, m, m2)
|
||||
├── quantity_design (설계 수량)
|
||||
├── quantity_actual (실제 수량, 사용자 수정 가능)
|
||||
├── unit_price (단가)
|
||||
├── total_price (수량 × 단가)
|
||||
├── standard_reference (설계 기준 참고 자료)
|
||||
├── computed_at
|
||||
└── data (JSONB — 계산 과정 메모)
|
||||
```
|
||||
|
||||
**설계 의도:**
|
||||
- 구조물은 횡단면별로 배치
|
||||
- 수량 항목은 자동 계산 + 사용자 수정 가능
|
||||
- 단가와 기준 추적으로 감시/감독 기록 남김
|
||||
|
||||
### 2.8 산출물 관리 (outputs, output_files)
|
||||
```
|
||||
outputs
|
||||
├── id (PK)
|
||||
├── project_id (FK → projects)
|
||||
├── output_type (estimation_excel, drawing_dxf, report_pdf, all_bundle)
|
||||
├── status (GENERATING, COMPLETE, FAILED)
|
||||
├── generated_by (FK → users)
|
||||
├── generated_at
|
||||
├── version (프로젝트 확정 단계에서 자동 증가)
|
||||
├── metadata (JSONB)
|
||||
│ ├── template_used
|
||||
│ ├── company_name, project_name
|
||||
│ ├── total_cost
|
||||
│ └── generation_time_sec
|
||||
└── created_at
|
||||
|
||||
output_files
|
||||
├── id (PK)
|
||||
├── output_id (FK → outputs)
|
||||
├── file_type (xlsx, pdf, dxf, dwg, json, zip)
|
||||
├── original_filename
|
||||
├── stored_path (storage/{...}/outputs/{output_type}/{filename})
|
||||
├── file_size_mb
|
||||
├── created_at
|
||||
└── download_count (통계)
|
||||
```
|
||||
|
||||
**설계 의도:**
|
||||
- 산출물 생성 이력 추적
|
||||
- 같은 프로젝트 다중 버전 관리 (재산출 가능)
|
||||
- 다운로드 통계로 사용 현황 파악
|
||||
|
||||
### 2.9 변경 이력 및 감시 (audit_logs, change_logs)
|
||||
```
|
||||
audit_logs (접근 제어 감시)
|
||||
├── id (PK)
|
||||
├── user_id (FK → users)
|
||||
├── action (CREATE, READ, UPDATE, DELETE, EXPORT)
|
||||
├── entity_type (projects, routes, structures, etc.)
|
||||
├── entity_id
|
||||
├── timestamp
|
||||
├── ip_address
|
||||
└── details (JSONB)
|
||||
|
||||
change_logs (설계 변경 기록)
|
||||
├── id (PK)
|
||||
├── project_id (FK → projects)
|
||||
├── changed_by (FK → users)
|
||||
├── changed_at
|
||||
├── entity_type (routes, cross_sections, structures)
|
||||
├── entity_id
|
||||
├── old_value (JSONB)
|
||||
├── new_value (JSONB)
|
||||
└── reason (사용자 입력 선택)
|
||||
```
|
||||
|
||||
**설계 의도:**
|
||||
- 보안 감시 (audit_logs)
|
||||
- 설계 변경 추적 및 롤백 가능성 (change_logs)
|
||||
|
||||
---
|
||||
|
||||
## 3. 파일시스템 디렉토리 구조
|
||||
|
||||
```
|
||||
storage/
|
||||
├── {company_slug}/
|
||||
│ ├── {user_slug}/
|
||||
│ │ └── {project_uuid}/
|
||||
│ │ ├── raw/ # 입력 원본
|
||||
│ │ │ ├── las/
|
||||
│ │ │ ├── tif/
|
||||
│ │ │ ├── prj/
|
||||
│ │ │ ├── tfw/
|
||||
│ │ │ └── dwg_dxf/
|
||||
│ │ ├── processed/ # 중간 산출물 (DB에 메타데이터만 저장)
|
||||
│ │ │ ├── surface/
|
||||
│ │ │ │ ├── dem_grid_2m.tif
|
||||
│ │ │ │ ├── tin_mesh.obj / .gltf
|
||||
│ │ │ │ └── contours.geojson
|
||||
│ │ │ ├── points/
|
||||
│ │ │ │ ├── ground_filtered.las
|
||||
│ │ │ │ └── ground_sampled_10m.ply
|
||||
│ │ │ └── tiles/
|
||||
│ │ │ └── (3D Tiles / MVT 렌더링 최적화)
|
||||
│ │ ├── sections/ # 단면 데이터 (JSON)
|
||||
│ │ │ ├── longitudinal.json
|
||||
│ │ │ ├── cross_0000m.json
|
||||
│ │ │ ├── cross_0020m.json
|
||||
│ │ │ └── ...
|
||||
│ │ └── outputs/ # 최종 산출물
|
||||
│ │ ├── estimation/
|
||||
│ │ │ ├── v1_2025-07-05.xlsx
|
||||
│ │ │ ├── v2_2025-07-10.xlsx
|
||||
│ │ │ └── ...
|
||||
│ │ ├── drawings/
|
||||
│ │ │ ├── v1_base.dxf
|
||||
│ │ │ ├── v1_detailed.dxf
|
||||
│ │ │ └── ...
|
||||
│ │ ├── reports/
|
||||
│ │ │ ├── v1_report.pdf
|
||||
│ │ │ └── ...
|
||||
│ │ └── bundles/
|
||||
│ │ ├── v1_20250705.zip # 완전 묶음
|
||||
│ │ └── ...
|
||||
│ └── {project_uuid2}/
|
||||
│ └── ...
|
||||
├── temp/ # 임시 처리 파일 (일정 기간 후 삭제)
|
||||
└── cache/ # 렌더링 캐시 (3D Tiles, 이미지 타일)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 주요 쿼리 패턴 및 인덱스 전략
|
||||
|
||||
### 4.1 자주 사용할 조회 패턴
|
||||
```sql
|
||||
-- 사용자의 전체 프로젝트 목록 (상태별 필터)
|
||||
SELECT * FROM projects
|
||||
WHERE user_id = :user_id AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- 프로젝트의 최신 경로
|
||||
SELECT * FROM routes
|
||||
WHERE project_id = :project_id AND status IN ('CONFIRMED', 'DRAFT')
|
||||
ORDER BY computed_at DESC LIMIT 1;
|
||||
|
||||
-- 경로의 횡단면 일괄 조회 (웹 렌더링)
|
||||
SELECT chainage_m, geometry FROM cross_sections
|
||||
WHERE route_id = :route_id AND status = 'CONFIRMED'
|
||||
ORDER BY chainage_m;
|
||||
|
||||
-- 수량 항목별 비용 집계
|
||||
SELECT category, SUM(total_price) as subtotal
|
||||
FROM quantity_items
|
||||
WHERE project_id = :project_id
|
||||
GROUP BY category;
|
||||
|
||||
-- 공간 범위 쿼리 (지도 렌더링)
|
||||
SELECT id, geometry FROM routes
|
||||
WHERE project_id = :project_id
|
||||
AND ST_Intersects(geometry, ST_GeomFromText(:bbox_wkt));
|
||||
```
|
||||
|
||||
### 4.2 권장 인덱스
|
||||
```sql
|
||||
-- 사용자별 프로젝트 조회
|
||||
CREATE INDEX idx_projects_user_deleted
|
||||
ON projects(user_id, deleted_at);
|
||||
|
||||
-- 프로젝트별 경로 최신순
|
||||
CREATE INDEX idx_routes_project_computed
|
||||
ON routes(project_id, computed_at DESC);
|
||||
|
||||
-- 경로의 횡단면 측점 순
|
||||
CREATE INDEX idx_cross_sections_route_chainage
|
||||
ON cross_sections(route_id, chainage_m);
|
||||
|
||||
-- PostGIS 공간 인덱스
|
||||
CREATE INDEX idx_routes_geometry
|
||||
ON routes USING GIST(geometry);
|
||||
|
||||
CREATE INDEX idx_cross_sections_geometry
|
||||
ON cross_sections USING GIST(geometry);
|
||||
|
||||
-- 수량 항목 범위 쿼리
|
||||
CREATE INDEX idx_quantity_project_category
|
||||
ON quantity_items(project_id, category);
|
||||
|
||||
-- 감시 로그 시간순
|
||||
CREATE INDEX idx_audit_logs_timestamp
|
||||
ON audit_logs(user_id, timestamp DESC);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. API 라우터 구조 (FastAPI)
|
||||
|
||||
### 5.1 라우터 계층화
|
||||
```
|
||||
routers/
|
||||
├── auth.py # 인증 (로그인, 회원가입, 토큰 갱신)
|
||||
├── users.py # 사용자 프로필
|
||||
├── companies.py # 회사 관리
|
||||
├── projects.py # 프로젝트 CRUD + 상태 관리
|
||||
├── files.py # 파일 업로드/다운로드
|
||||
├── workflows/
|
||||
│ ├── wf1_surface.py # WF1: 지표면 분석 (LAS → DEM/mesh)
|
||||
│ ├── wf2_route.py # WF2: 경로 설계
|
||||
│ ├── wf3_sections.py # WF3: 종횡단 생성
|
||||
│ ├── wf4_design.py # WF4: 상세 설계 (구조물)
|
||||
│ ├── wf5_quantity.py # WF5: 수량 산출
|
||||
│ └── wf6_outputs.py # WF6: 산출물 생성 (Excel/PDF/DXF)
|
||||
├── analysis.py # 분석 결과 조회 (읽기 전용)
|
||||
└── admin.py # 관리자 기능 (감시 로그 등)
|
||||
```
|
||||
|
||||
### 5.2 주요 엔드포인트 예시
|
||||
```
|
||||
POST /api/auth/register 사용자 가입
|
||||
POST /api/auth/login 로그인
|
||||
GET /api/users/me 현재 사용자 정보
|
||||
PATCH /api/users/{user_id} 프로필 수정
|
||||
|
||||
POST /api/projects 프로젝트 생성
|
||||
GET /api/projects 사용자 프로젝트 목록
|
||||
GET /api/projects/{proj_id} 프로젝트 상세
|
||||
PATCH /api/projects/{proj_id} 프로젝트 수정 (이름, 메모 등)
|
||||
|
||||
POST /api/projects/{proj_id}/files/upload 파일 업로드
|
||||
GET /api/projects/{proj_id}/files 파일 목록
|
||||
|
||||
POST /api/projects/{proj_id}/wf1/analyze WF1 시작
|
||||
GET /api/projects/{proj_id}/wf1/status WF1 진행률
|
||||
GET /api/projects/{proj_id}/wf1/surface-models 생성된 표면
|
||||
|
||||
POST /api/projects/{proj_id}/wf2/calculate-route WF2 경로 계산
|
||||
GET /api/projects/{proj_id}/wf2/routes 경로 목록
|
||||
PATCH /api/projects/{proj_id}/wf2/routes/{route_id} 경로 수정
|
||||
|
||||
GET /api/projects/{proj_id}/wf3/cross-sections 횡단면 목록
|
||||
GET /api/projects/{proj_id}/wf3/sections/{ch} 특정 측점 상세
|
||||
|
||||
POST /api/projects/{proj_id}/wf4/structures 구조물 추가
|
||||
PATCH /api/projects/{proj_id}/wf4/structures/{id} 구조물 수정
|
||||
|
||||
GET /api/projects/{proj_id}/wf5/quantities 수량 항목
|
||||
PATCH /api/projects/{proj_id}/wf5/quantities/{id} 수량 수정
|
||||
|
||||
POST /api/projects/{proj_id}/wf6/generate-output 산출물 생성
|
||||
GET /api/projects/{proj_id}/outputs 산출물 목록
|
||||
GET /api/projects/{proj_id}/outputs/{id}/download 다운로드
|
||||
|
||||
POST /api/projects/{proj_id}/confirm 프로젝트 확정
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 비동기 작업 및 백그라운드 처리 (Celery / asyncio)
|
||||
|
||||
### 6.1 장시간 작업 큐
|
||||
```
|
||||
작업 목록:
|
||||
1. WF1 분석 (LAS 필터링 → 지형 변환) — 수십 분
|
||||
2. WF2 경로 계산 (최적화 알고리즘) — 수 분
|
||||
3. WF6 산출물 생성 (DXF/Excel/PDF) — 수 분
|
||||
|
||||
구현 방식:
|
||||
- Celery + Redis (프로덕션)
|
||||
또는
|
||||
- FastAPI BackgroundTasks + asyncio (초기 단계)
|
||||
|
||||
상태 추적:
|
||||
- jobs 테이블에 task_id, status, progress, error_msg 저장
|
||||
- WebSocket 또는 polling으로 진행률 클라이언트에 전송
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 마이그레이션 및 테이블 생성 전략
|
||||
|
||||
### 7.1 alembic 기반 버전 관리
|
||||
```
|
||||
alembic/
|
||||
├── versions/
|
||||
│ ├── 001_initial_schema.py (users, companies, projects)
|
||||
│ ├── 002_spatial_tables.py (routes, cross_sections, PostGIS)
|
||||
│ ├── 003_audit_and_logs.py (audit_logs, change_logs)
|
||||
│ └── ...
|
||||
└── env.py
|
||||
```
|
||||
|
||||
### 7.2 초기 마이그레이션 단계
|
||||
1. 사용자 / 회사 / 프로젝트 (로그인 후 필수)
|
||||
2. 입력 파일 메타데이터 (파일 업로드 후 필수)
|
||||
3. 경로 / 단면 / 구조물 (워크플로우 실행 후 필수)
|
||||
4. 감시 로그 (선택, 나중에 추가 가능)
|
||||
|
||||
---
|
||||
|
||||
## 8. 데이터 일관성 및 검증 규칙
|
||||
|
||||
### 8.1 데이터 무결성 제약
|
||||
```sql
|
||||
-- 외래키 제약
|
||||
ALTER TABLE projects
|
||||
ADD CONSTRAINT fk_projects_user FOREIGN KEY (user_id)
|
||||
REFERENCES users(id) ON DELETE RESTRICT;
|
||||
|
||||
-- Unique 제약
|
||||
ALTER TABLE users ADD CONSTRAINT uq_users_email UNIQUE(email);
|
||||
ALTER TABLE companies ADD CONSTRAINT uq_companies_name UNIQUE(name);
|
||||
|
||||
-- Check 제약 (상태 머신)
|
||||
ALTER TABLE projects ADD CONSTRAINT check_project_status
|
||||
CHECK (status IN ('NEW', 'ANALYZING', 'WF1_COMPLETE', ..., 'DONE'));
|
||||
```
|
||||
|
||||
### 8.2 좌표계 검증
|
||||
```python
|
||||
# Python 유효성 검사
|
||||
from pyproj import CRS
|
||||
|
||||
def validate_crs(epsg_code: int):
|
||||
try:
|
||||
crs = CRS.from_epsg(epsg_code)
|
||||
return True
|
||||
except:
|
||||
raise ValueError(f"Invalid EPSG code: {epsg_code}")
|
||||
|
||||
# 좌표 변환 시 예외 처리
|
||||
def transform_coordinates(src_epsg, tgt_epsg, coords):
|
||||
transformer = Transformer.from_crs(f"EPSG:{src_epsg}", f"EPSG:{tgt_epsg}")
|
||||
return transformer.transform(coords.x, coords.y)
|
||||
```
|
||||
|
||||
### 8.3 입력 파일 검증
|
||||
```python
|
||||
# 파일 타입 검증
|
||||
ALLOWED_EXTENSIONS = {
|
||||
'las': laspy.read,
|
||||
'tif': rasterio.open,
|
||||
'prj': lambda f: f.read().decode('utf-8'),
|
||||
'dxf': ezdxf.readfile,
|
||||
}
|
||||
|
||||
# 파일 크기 제한 (config에서 읽기)
|
||||
MAX_FILE_SIZE_MB = 500
|
||||
|
||||
def validate_upload(file, file_type):
|
||||
if file.size > MAX_FILE_SIZE_MB * 1024 * 1024:
|
||||
raise ValueError(f"File too large: {file.size / 1024 / 1024:.1f}MB")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 초기 vs 장기 구현 로드맵
|
||||
|
||||
### Phase 1 (초기, 회원가입 MVP — 2-3주)
|
||||
**필수 테이블:** users, companies, projects, input_files, audit_logs
|
||||
**API:** 인증, 프로젝트 CRUD, 파일 업로드
|
||||
**워크플로우:** 파일 입력(B03)까지만 DB 연동
|
||||
|
||||
### Phase 2 (WF1-WF3, 분석 — 4-6주)
|
||||
**추가 테이블:** surface_models, routes, cross_sections, change_logs
|
||||
**API:** WF1 분석 시작, 경로 계산, 단면 조회
|
||||
**백그라운드:** Celery 작업 큐 도입
|
||||
|
||||
### Phase 3 (WF4-WF6, 산출물 — 6-8주)
|
||||
**추가 테이블:** structures, quantity_items, outputs, output_files
|
||||
**API:** 구조물 추가, 수량 산출, 산출물 생성
|
||||
**기능:** Excel/PDF/DXF 자동 생성
|
||||
|
||||
### Phase 4 (협업 및 감시, 선택)
|
||||
**추가 기능:** 팀 공유 프로젝트, 권한 세분화, 비용 청구
|
||||
**감시:** audit_logs 기반 컴플라이언스 리포트
|
||||
|
||||
---
|
||||
|
||||
## 10. 보안 및 성능 고려사항
|
||||
|
||||
### 10.1 보안
|
||||
- **인증:** JWT 토큰 (httponly cookie)
|
||||
- **권한:** user_id 기반 row-level security (RLS) + 애플리케이션 검증
|
||||
- **파일 접근:** 서명된 URL 또는 토큰 검증
|
||||
- **감시:** audit_logs에 모든 수정 기록
|
||||
- **SQL injection 방지:** ORM (SQLAlchemy) + 파라미터화된 쿼리
|
||||
|
||||
### 10.2 성능 최적화
|
||||
- **쿼리:** 인덱스 전략 (위 4.2 참고)
|
||||
- **캐싱:** Redis에 자주 조회하는 메타데이터 캐시 (설정값, 사용자 권한 등)
|
||||
- **파일 저장소:** 클라우드 스토리지 옵션 (AWS S3, GCS) 고려
|
||||
- **3D 렌더링:** LOD/타일링으로 대용량 지형 최적화
|
||||
- **비동기:** 장시간 작업은 백그라운드 큐에서 처리
|
||||
|
||||
### 10.3 백업 및 복구
|
||||
- **DB 백업:** 일 1회 자동 백업 (point-in-time recovery)
|
||||
- **파일 백업:** 중요 산출물은 별도 저장소에 복제
|
||||
- **버전 관리:** change_logs로 프로젝트 상태 롤백 가능
|
||||
|
||||
---
|
||||
|
||||
## 11. 검토 항목 및 다음 단계
|
||||
|
||||
### 체크리스트
|
||||
- [ ] ERD(Entity-Relationship Diagram) 작성 및 리뷰
|
||||
- [ ] 좌표계 변환 로직 검증 (EPSG 코드)
|
||||
- [ ] 파일 저장소 정책 확정 (로컬 vs 클라우드)
|
||||
- [ ] 권한 모델 세밀화 (팀 공유 필요성 판단)
|
||||
- [ ] 비용 청구 로직 정의 (필요 시)
|
||||
- [ ] 성능 테스트 (대용량 파일, 대량 사용자)
|
||||
- [ ] 법적 데이터 보관 기간 확인
|
||||
|
||||
### 다음 작업
|
||||
1. **ERD 다이어그램 생성** — dbdiagram.io 또는 draw.io
|
||||
2. **alembic 마이그레이션 초안 작성** — Phase 1 테이블부터
|
||||
3. **FastAPI 모델(Pydantic) 정의** — 입력/출력 스키마
|
||||
4. **API 문서(OpenAPI)** — Swagger 자동 생성
|
||||
5. **테스트 DB 구성** — 로컬 PostgreSQL + 샘플 데이터
|
||||
|
||||
---
|
||||
|
||||
## 12. 참고: 구현 언어 & 도구
|
||||
|
||||
| 영역 | 도구 | 용도 |
|
||||
|------|------|------|
|
||||
| **ORM** | SQLAlchemy 2.0 + asyncpg | async DB 쿼리 |
|
||||
| **마이그레이션** | Alembic | DB 버전 관리 |
|
||||
| **검증** | Pydantic v2 | 입력 데이터 검증 |
|
||||
| **공간 쿼리** | GeoAlchemy2 + PostGIS | 기하학 연산 |
|
||||
| **비동기** | Celery + Redis 또는 FastAPI BackgroundTasks | 장시간 작업 |
|
||||
| **파일 처리** | laspy, rasterio, geopandas, ezdxf | 공간 데이터 |
|
||||
| **문서 생성** | openpyxl, reportlab, ezdxf | Excel/PDF/DXF 출력 |
|
||||
|
||||
---
|
||||
|
||||
**이 제안서는 기초 설계 단계입니다. 다음 리뷰에서 변경, 추가, 삭제 사항을 정리한 후 ERD 및 마이그레이션 코드로 구체화하겠습니다.**
|
||||
@@ -9,8 +9,10 @@ users
|
||||
├── email (TEXT, UNIQUE) — 로그인 이메일
|
||||
├── password_hash (TEXT) — 비밀번호 암호화 저장
|
||||
├── name (TEXT) — 사용자 이름
|
||||
├── position (TEXT) — 직급 (예: 과장, 대리, 사원)
|
||||
├── department (TEXT) — 부서명 (예: 설계팀, 영업팀)
|
||||
├── phone (TEXT) — 연락처 (예: 010-1234-5678)
|
||||
├── company_id (INT, FK → companies.id) — 소속 회사
|
||||
├── role (TEXT) — 역할: admin, user, guest
|
||||
├── created_at (TIMESTAMP) — 가입일
|
||||
├── updated_at (TIMESTAMP) — 수정일
|
||||
└── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete)
|
||||
@@ -21,9 +23,14 @@ users
|
||||
companies
|
||||
├── id (INT, PK) — 회사 고유 번호
|
||||
├── name (TEXT, UNIQUE) — 회사명
|
||||
├── business_registration_number (TEXT, UNIQUE) — 사업자등록번호
|
||||
├── business_address (TEXT) — 사업장 주소
|
||||
├── business_owner (TEXT) — 사업주 이름
|
||||
├── business_status (TEXT) — 기업 상태 (예: 활동중, 폐업, 휴업)
|
||||
├── created_by (INT, FK → users.id) — 회사 생성자
|
||||
├── created_at (TIMESTAMP) — 생성일
|
||||
└── updated_at (TIMESTAMP) — 수정일
|
||||
├── updated_at (TIMESTAMP) — 수정일
|
||||
└── deleted_at (TIMESTAMP, NULL) — 삭제일 (soft delete)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -67,14 +74,14 @@ project_versions
|
||||
|
||||
## 3. 파일 & 입력 데이터 그룹
|
||||
|
||||
### 3-1. input_files 테이블
|
||||
### 3-1. input_files 테이블 (원본 입력 파일 — 영구저장소)
|
||||
```
|
||||
input_files
|
||||
├── id (INT, PK) — 파일 고유 번호
|
||||
├── project_id (UUID, FK → projects.id) — 속한 프로젝트
|
||||
├── file_type (TEXT) — 파일 종류: las, tif, tfw, prj, dxf, dwg, other
|
||||
├── original_filename (TEXT) — 원래 파일명 (예: "cloud_merged.las")
|
||||
├── stored_path (TEXT) — 저장 경로 (예: "storage/.../raw/las/cloud_merged.las")
|
||||
├── raw_file_path (TEXT) — 원본 저장 경로 (예: "storage/.../raw/las/cloud_merged.las")
|
||||
├── file_size_mb (FLOAT) — 파일 크기 (MB)
|
||||
├── upload_by (INT, FK → users.id) — 업로드한 사용자
|
||||
├── upload_at (TIMESTAMP) — 업로드 날짜
|
||||
@@ -86,11 +93,16 @@ input_files
|
||||
└── status (TEXT) — 상태: UPLOADED, PROCESSED, ARCHIVED
|
||||
```
|
||||
|
||||
### 3-2. point_cloud_metadata 테이블
|
||||
### 3-2. processed_point_cloud 테이블 (변환된 포인트클라우드 데이터 — 영구저장소)
|
||||
```
|
||||
point_cloud_metadata
|
||||
├── id (INT, PK)
|
||||
├── input_file_id (INT, FK → input_files.id) — LAS 파일 참조
|
||||
processed_point_cloud
|
||||
├── id (INT, PK) — 변환 데이터 고유 번호
|
||||
├── input_file_id (INT, FK → input_files.id) — 원본 LAS 파일 참조
|
||||
├── project_id (UUID, FK → projects.id)
|
||||
├── process_type (TEXT) — 변환 방식: filtered, sampled, classified 등
|
||||
├── processed_file_path (TEXT) — 변환된 파일 경로 (예: "storage/.../processed/las/ground_filtered.las")
|
||||
├── converted_format (TEXT) — 변환 포맷: las, ply, laz 등 (컴퓨터가 빠르게 읽기 위해)
|
||||
├── converted_file_path (TEXT) — 변환 포맷 저장 경로 (예: "storage/.../processed/ply/cloud_sampled_10m.ply")
|
||||
├── point_count (INT) — 포인트 개수
|
||||
├── min_z (FLOAT) — 최저 높이
|
||||
├── max_z (FLOAT) — 최고 높이
|
||||
@@ -100,59 +112,70 @@ point_cloud_metadata
|
||||
├── y_min (FLOAT) — 최소 위도
|
||||
├── y_max (FLOAT) — 최대 위도
|
||||
├── density_per_sqm (FLOAT) — 단위 면적당 포인트 밀도
|
||||
└── classification_summary (JSONB) — 분류 요약
|
||||
├── ground (지표면 포인트)
|
||||
├── vegetation (식생)
|
||||
├── building (건물)
|
||||
└── ...
|
||||
├── classification_summary (JSONB) — 분류 요약
|
||||
│ ├── ground (지표면 포인트)
|
||||
│ ├── vegetation (식생)
|
||||
│ ├── building (건물)
|
||||
│ └── ...
|
||||
├── processing_params (JSONB) — 변환 파라미터
|
||||
├── processed_at (TIMESTAMP) — 변환 완료 날짜
|
||||
└── status (TEXT) — 상태: PROCESSING, COMPLETE, FAILED
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 지표면 & 분석 결과 그룹
|
||||
|
||||
### 4-1. surface_models 테이블 (WF1 출력)
|
||||
### 4-1. surface_models 테이블 (WF1 출력 — 지표면 분석 결과)
|
||||
```
|
||||
surface_models
|
||||
├── id (INT, PK) — 지표면 모델 고유 번호
|
||||
├── project_id (UUID, FK → projects.id)
|
||||
├── model_type (TEXT) — 모델 종류: dem_grid, tin, mesh_triangulated, contour_lines
|
||||
├── source_file_id (INT, FK → input_files.id) — 원본 LAS 파일
|
||||
├── processed_cloud_id (INT, FK → processed_point_cloud.id) — 변환된 포인트클라우드 참조
|
||||
├── status (TEXT) — 상태: PROCESSING, COMPLETE, FAILED
|
||||
├── crs_epsg (INT) — 좌표계
|
||||
├── resolution_m (FLOAT) — 해상도 (래스터인 경우, NULL 가능)
|
||||
├── stored_path (TEXT) — 저장 경로 (예: "storage/.../processed/surface/dem.tif")
|
||||
├── model_file_path (TEXT) — 모델 저장 경로 (예: "storage/.../processed/surface/dem.tif")
|
||||
├── bounds (GEOMETRY) — 모델 범위 (다각형)
|
||||
├── metadata (JSONB) — 생성 파라미터
|
||||
├── generation_params (JSONB) — 생성 파라미터
|
||||
│ ├── filter_type (필터링 방식)
|
||||
│ ├── creation_time (생성 시각)
|
||||
│ └── ...
|
||||
│ ├── algorithm (사용 알고리즘)
|
||||
│ ├── params (알고리즘 파라미터)
|
||||
│ └── creation_time (생성 시각)
|
||||
├── created_at (TIMESTAMP)
|
||||
└── completed_at (TIMESTAMP)
|
||||
```
|
||||
|
||||
### 4-2. terrain_layers 테이블
|
||||
### 4-2. terrain_layers 테이블 (WF1 산출물 — 각 지형 레이어)
|
||||
```
|
||||
terrain_layers
|
||||
├── id (INT, PK)
|
||||
├── id (INT, PK) — 레이어 고유 번호
|
||||
├── surface_model_id (INT, FK → surface_models.id)
|
||||
├── layer_name (TEXT) — 레이어명 (예: "지표", "제1층", "제2층" 등)
|
||||
├── geometry_type (TEXT) — 기하 종류: POINTCLOUD, GRID, MESH, CONTOUR
|
||||
├── stored_path (TEXT) — 저장 경로 (GeoJSON, GeoTIFF, LAS 등)
|
||||
└── statistics (JSONB) — 통계
|
||||
├── min_z, max_z, mean_slope
|
||||
└── ...
|
||||
├── layer_file_path (TEXT) — 레이어 파일 저장 경로 (예: "storage/.../processed/surface/layer_1.geojson")
|
||||
├── file_format (TEXT) — 파일 형식: geojson, geotiff, ply, obj 등
|
||||
├── file_size_mb (FLOAT) — 파일 크기
|
||||
├── bounds (GEOMETRY) — 레이어 범위
|
||||
├── statistics (JSONB) — 통계
|
||||
│ ├── min_z, max_z, mean_slope
|
||||
│ ├── point_count (포인트 개수)
|
||||
│ └── ...
|
||||
└── created_at (TIMESTAMP)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 경로 & 설계 그룹
|
||||
|
||||
### 5-1. routes 테이블 (WF2 출력)
|
||||
### 5-1. routes 테이블 (WF2 출력 — 경로 설계 결과)
|
||||
```
|
||||
routes
|
||||
├── id (INT, PK) — 경로 고유 번호
|
||||
├── project_id (UUID, FK → projects.id)
|
||||
├── surface_model_id (INT, FK → surface_models.id) — 기반 지표면 모델
|
||||
├── status (TEXT) — 상태: DRAFT, CONFIRMED, ARCHIVED
|
||||
├── start_point (GEOMETRY(Point)) — 시작점 좌표 (3D)
|
||||
├── end_point (GEOMETRY(Point)) — 종료점 좌표 (3D)
|
||||
@@ -170,6 +193,7 @@ routes
|
||||
│ ├── cost_weights (비용함수 가중치)
|
||||
│ └── ...
|
||||
├── geometry (GEOMETRY(LineString, Z)) — 3D 경로 좌표 열
|
||||
├── route_data_path (TEXT) — 경로 데이터 저장 경로 (예: "storage/.../computed/route/route_main.geojson")
|
||||
├── computed_at (TIMESTAMP) — 계산 완료 날짜
|
||||
└── created_at (TIMESTAMP)
|
||||
```
|
||||
@@ -204,7 +228,7 @@ route_statistics
|
||||
|
||||
## 6. 종단면 & 횡단면 그룹
|
||||
|
||||
### 6-1. longitudinal_sections 테이블 (WF3 출력)
|
||||
### 6-1. longitudinal_sections 테이블 (WF3 출력 — 종단면)
|
||||
```
|
||||
longitudinal_sections
|
||||
├── id (INT, PK) — 종단면 고유 번호
|
||||
@@ -217,10 +241,11 @@ longitudinal_sections
|
||||
│ ├── elevations ([100.5, 102.3, 101.8, ...]) — 지표 표고 배열
|
||||
│ ├── grades ([2.5, 1.8, 1.0, ...]) — 경사도 배열 (%)
|
||||
│ └── design_elevations ([100.0, 102.0, 101.5, ...]) — 설계 표고
|
||||
└── stored_path (TEXT) — 저장 경로 (예: "storage/.../sections/longitudinal.json")
|
||||
├── longitudinal_file_path (TEXT) — 저장 경로 (예: "storage/.../computed/sections/longitudinal.json")
|
||||
└── status (TEXT) — 상태: DRAFT, CONFIRMED
|
||||
```
|
||||
|
||||
### 6-2. cross_sections 테이블 (WF3 출력)
|
||||
### 6-2. cross_sections 테이블 (WF3 출력 — 횡단면)
|
||||
```
|
||||
cross_sections
|
||||
├── id (INT, PK) — 횡단면 고유 번호
|
||||
@@ -238,14 +263,15 @@ cross_sections
|
||||
│ ├── fill_volume_m3 (FLOAT) — 성토량
|
||||
│ ├── structures ([]) — 이 단면 내 구조물 ID 배열
|
||||
│ └── notes (TEXT) — 메모
|
||||
└── stored_path (TEXT) — 저장 경로 (예: "storage/.../sections/cross_0020m.json")
|
||||
├── cross_section_file_path (TEXT) — 저장 경로 (예: "storage/.../computed/sections/cross_0020m.json")
|
||||
└── status (TEXT) — 상태: DRAFT, CONFIRMED
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 설계 & 구조물 그룹
|
||||
|
||||
### 7-1. structures 테이블 (WF4 출력)
|
||||
### 7-1. structures 테이블 (WF4 출력 — 구조물)
|
||||
```
|
||||
structures
|
||||
├── id (INT, PK) — 구조물 고유 번호
|
||||
@@ -262,11 +288,13 @@ structures
|
||||
├── unit_price (FLOAT) — 단가
|
||||
├── geometry (GEOMETRY(Polygon)) — 3D 배치 도형
|
||||
├── design_notes (JSONB) — 설계 노트
|
||||
├── structure_data_path (TEXT) — 구조물 데이터 저장 경로 (예: "storage/.../computed/structures/struct_0020m_001.json")
|
||||
├── last_modified_by (INT, FK → users.id) — 마지막 수정자
|
||||
└── created_at (TIMESTAMP)
|
||||
├── created_at (TIMESTAMP)
|
||||
└── updated_at (TIMESTAMP)
|
||||
```
|
||||
|
||||
### 7-2. quantity_items 테이블 (WF5 출력)
|
||||
### 7-2. quantity_items 테이블 (WF5 출력 — 수량 산출)
|
||||
```
|
||||
quantity_items
|
||||
├── id (INT, PK) — 수량 항목 고유 번호
|
||||
@@ -280,8 +308,10 @@ quantity_items
|
||||
├── total_price (FLOAT) — 소계 (quantity_actual × unit_price)
|
||||
├── standard_reference (TEXT) — 기준 참고 자료
|
||||
├── computed_at (TIMESTAMP) — 계산 날짜
|
||||
├── quantity_data_path (TEXT) — 수량 데이터 저장 경로 (예: "storage/.../computed/quantities/items.json")
|
||||
└── data (JSONB) — 계산 과정 메모
|
||||
├── formula (계산식)
|
||||
├── source (데이터 출처)
|
||||
└── ...
|
||||
```
|
||||
|
||||
@@ -289,7 +319,7 @@ quantity_items
|
||||
|
||||
## 8. 산출물 & 문서 그룹
|
||||
|
||||
### 8-1. outputs 테이블 (WF6 출력)
|
||||
### 8-1. outputs 테이블 (WF6 출력 — 최종 산출물 세트)
|
||||
```
|
||||
outputs
|
||||
├── id (INT, PK) — 산출물 세트 고유 번호
|
||||
@@ -298,7 +328,8 @@ outputs
|
||||
├── status (TEXT) — 상태: GENERATING, COMPLETE, FAILED
|
||||
├── generated_by (INT, FK → users.id) — 생성자
|
||||
├── generated_at (TIMESTAMP) — 생성 날짜
|
||||
├── version (INT) — 버전 (프로젝트 확정 시 자동 증가)
|
||||
├── version (INT) — 버전 (프로젝트 확정 단계에서 자동 증가)
|
||||
├── outputs_directory_path (TEXT) — 산출물 저장 폴더 (예: "storage/.../outputs/v1/")
|
||||
└── metadata (JSONB) — 메타데이터
|
||||
├── template_used (사용한 양식)
|
||||
├── company_name (회사명)
|
||||
@@ -307,14 +338,14 @@ outputs
|
||||
└── generation_time_sec (생성 소요 시간)
|
||||
```
|
||||
|
||||
### 8-2. output_files 테이블
|
||||
### 8-2. output_files 테이블 (최종 산출 파일들)
|
||||
```
|
||||
output_files
|
||||
├── id (INT, PK) — 파일 고유 번호
|
||||
├── output_id (INT, FK → outputs.id)
|
||||
├── file_type (TEXT) — 파일 형식: xlsx, pdf, dxf, dwg, json, zip
|
||||
├── original_filename (TEXT) — 파일명 (예: "견적서_v1.xlsx")
|
||||
├── stored_path (TEXT) — 저장 경로 (예: "storage/.../outputs/estimation/견적서_v1.xlsx")
|
||||
├── output_file_path (TEXT) — 저장 경로 (예: "storage/.../outputs/v1/견적서.xlsx")
|
||||
├── file_size_mb (FLOAT) — 파일 크기
|
||||
├── created_at (TIMESTAMP) — 생성 날짜
|
||||
└── download_count (INT) — 다운로드 횟수
|
||||
@@ -385,35 +416,100 @@ users (사용자)
|
||||
|
||||
---
|
||||
|
||||
## 11. 파일시스템 경로와 DB 링크
|
||||
## 11. 파일시스템 경로와 DB 링크 (워크플로우 기반 구조)
|
||||
|
||||
### 파일시스템 구조 (6단계 워크플로우에 맞춤)
|
||||
|
||||
```
|
||||
storage/
|
||||
├── {company_slug}/
|
||||
│ └── {user_slug}/
|
||||
│ └── {project_id}/ ← projects.storage_path
|
||||
│ ├── raw/ ← input_files.stored_path
|
||||
│ │ ├── las/
|
||||
│ │ ├── tif/
|
||||
│ │ ├── tfw/
|
||||
│ │ ├── prj/
|
||||
│ │ └── dxf/
|
||||
│ ├── processed/ ← surface_models.stored_path, terrain_layers.stored_path
|
||||
│ │ ├── surface/
|
||||
│ │ ├── points/
|
||||
│ │ └── tiles/
|
||||
│ ├── sections/ ← longitudinal_sections.stored_path, cross_sections.stored_path
|
||||
│ │ ├── longitudinal.json
|
||||
│ │ ├── cross_0000m.json
|
||||
│ │ ├── cross_0020m.json
|
||||
│ │ └── ...
|
||||
│ └── outputs/ ← output_files.stored_path
|
||||
│ ├── estimation/
|
||||
│ ├── drawings/
|
||||
│ ├── reports/
|
||||
│ │
|
||||
│ ├── B03_FileInput/ ← WF0: 파일 입력
|
||||
│ │ ├── input/ (원본 입력 파일들)
|
||||
│ │ │ ├── las/
|
||||
│ │ │ │ └── cloud_merged.las ← input_files.raw_file_path
|
||||
│ │ │ ├── tif/
|
||||
│ │ │ ├── tfw/
|
||||
│ │ │ ├── prj/
|
||||
│ │ │ └── dxf/
|
||||
│ │ └── metadata.json (파일 메타데이터)
|
||||
│ │
|
||||
│ ├── B04_wf1_Surface/ ← WF1: 지표면 분석
|
||||
│ │ ├── processed/ (변환된 포인트클라우드)
|
||||
│ │ │ ├── cloud_filtered.las ← processed_point_cloud.processed_file_path
|
||||
│ │ │ └── cloud_sampled.ply ← processed_point_cloud.converted_file_path
|
||||
│ │ ├── models/ (지표면 모델)
|
||||
│ │ │ ├── dem_2m.tif ← surface_models.model_file_path
|
||||
│ │ │ ├── tin_mesh.obj
|
||||
│ │ │ ├── layer_ground.geojson ← terrain_layers.layer_file_path
|
||||
│ │ │ ├── layer_1.geojson
|
||||
│ │ │ └── ...
|
||||
│ │ └── analysis_report.json
|
||||
│ │
|
||||
│ ├── B05_wf2_Route/ ← WF2: 경로 설계
|
||||
│ │ ├── route/
|
||||
│ │ │ ├── route_main.geojson ← routes.route_data_path
|
||||
│ │ │ ├── route_points.json
|
||||
│ │ │ └── route_statistics.json ← route_statistics 데이터
|
||||
│ │ └── design_params.json
|
||||
│ │
|
||||
│ ├── B06_wf3_ProfileCross/ ← WF3: 종횡단 생성
|
||||
│ │ ├── longitudinal/
|
||||
│ │ │ └── longitudinal.json ← longitudinal_sections.longitudinal_file_path
|
||||
│ │ ├── cross_sections/
|
||||
│ │ │ ├── cross_0000m.json ← cross_sections.cross_section_file_path
|
||||
│ │ │ ├── cross_0020m.json
|
||||
│ │ │ ├── cross_0040m.json
|
||||
│ │ │ └── ...
|
||||
│ │ └── sections_index.json
|
||||
│ │
|
||||
│ ├── B07_wf4_DesignDetail/ ← WF4: 상세 설계
|
||||
│ │ ├── structures/
|
||||
│ │ │ ├── struct_0020m_001.json ← structures.structure_data_path
|
||||
│ │ │ ├── struct_0020m_002.json
|
||||
│ │ │ ├── struct_0040m_001.json
|
||||
│ │ │ └── ...
|
||||
│ │ └── design_review.json
|
||||
│ │
|
||||
│ ├── B08_wf5_Quantity/ ← WF5: 수량 산출
|
||||
│ │ ├── quantities/
|
||||
│ │ │ └── items.json ← quantity_items.quantity_data_path
|
||||
│ │ ├── breakdown_by_category.json (항목별 집계)
|
||||
│ │ └── cost_summary.json (비용 요약)
|
||||
│ │
|
||||
│ └── B09_wf6_Estimation/ ← WF6: 견적·문서
|
||||
│ ├── v1/ ← outputs.outputs_directory_path
|
||||
│ │ ├── 견적서.xlsx ← output_files.output_file_path
|
||||
│ │ ├── 설계도.dxf
|
||||
│ │ ├── 보고서.pdf
|
||||
│ │ ├── 종단면도.pdf
|
||||
│ │ └── 횡단면도.pdf
|
||||
│ ├── v2/
|
||||
│ │ ├── 견적서.xlsx
|
||||
│ │ └── ...
|
||||
│ └── bundles/
|
||||
│ ├── v1_complete.zip (버전 1 전체 패키지)
|
||||
│ └── v2_complete.zip (버전 2 전체 패키지)
|
||||
```
|
||||
|
||||
**핵심 개념:**
|
||||
- **B03_FileInput/**: WF0 파일 입력 단계 (사용자 업로드 원본)
|
||||
- **B04_wf1_Surface/**: WF1 지표면 분석 결과 (DEM, TIN, 메시, 레이어)
|
||||
- **B05_wf2_Route/**: WF2 경로 설계 결과 (경로 GeoJSON)
|
||||
- **B06_wf3_ProfileCross/**: WF3 종횡단 생성 결과 (종단면, 횡단면 JSON)
|
||||
- **B07_wf4_DesignDetail/**: WF4 상세 설계 결과 (구조물 배치)
|
||||
- **B08_wf5_Quantity/**: WF5 수량 산출 결과 (수량 항목)
|
||||
- **B09_wf6_Estimation/**: WF6 최종 산출물 (Excel, PDF, DXF)
|
||||
|
||||
**장점:**
|
||||
✅ 워크플로우 단계와 폴더가 1:1 대응 → 직관적
|
||||
✅ 각 단계의 입력/출력이 명확히 분리
|
||||
✅ 각 단계 재실행 시 폴더만 초기화하면 됨
|
||||
✅ 버전 관리 (v1, v2, ...) 단순화
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 12. 핵심 쿼리 예제
|
||||
@@ -480,20 +576,86 @@ ORDER BY timestamp DESC;
|
||||
|
||||
## 요약
|
||||
|
||||
**총 17개 테이블:**
|
||||
1. users, companies
|
||||
2. projects, project_versions
|
||||
3. input_files, point_cloud_metadata
|
||||
4. surface_models, terrain_layers
|
||||
5. routes, route_points, route_statistics
|
||||
6. longitudinal_sections, cross_sections
|
||||
7. structures, quantity_items
|
||||
8. outputs, output_files
|
||||
9. audit_logs, change_logs
|
||||
### 주요 변경사항
|
||||
1. ✅ **users 테이블**: position(직급), department(부서), phone(연락처) 추가
|
||||
2. ✅ **companies 테이블**: business_registration_number, business_address, business_owner, business_status 추가
|
||||
3. ✅ **경로 관리 추가**: 모든 데이터의 저장소 경로를 DB에 기록
|
||||
4. ✅ **processed_point_cloud 테이블 신규**: LAS → 빠른 포맷 변환 추적
|
||||
5. ✅ **파일 경로 세분화**:
|
||||
- `raw_file_path`: 원본 파일 위치
|
||||
- `processed_file_path / converted_file_path`: 변환된 파일 위치
|
||||
- `*_file_path`: 각 워크플로우 단계별 산출물 위치
|
||||
|
||||
**다음 단계:**
|
||||
- [ ] PostgreSQL에 위 테이블 생성
|
||||
### 총 18개 테이블:
|
||||
1. **사용자/회사**: users, companies (2개)
|
||||
2. **프로젝트**: projects, project_versions (2개)
|
||||
3. **입력 데이터**: input_files, processed_point_cloud (2개)
|
||||
4. **지표면 분석**: surface_models, terrain_layers (2개)
|
||||
5. **경로 설계**: routes, route_points, route_statistics (3개)
|
||||
6. **단면 설계**: longitudinal_sections, cross_sections (2개)
|
||||
7. **구조물/수량**: structures, quantity_items (2개)
|
||||
8. **산출물**: outputs, output_files (2개)
|
||||
9. **감시/이력**: audit_logs, change_logs (2개)
|
||||
|
||||
### DB vs 파일시스템 역할 분리
|
||||
| 저장 위치 | 내용 | 예시 |
|
||||
|----------|------|------|
|
||||
| **DB** | 메타데이터 + 경로정보 | 파일명, 크기, 좌표계, 상태, 저장경로 |
|
||||
| **파일시스템** | 실제 파일 데이터 | LAS, DXF, Excel, PDF, JSON, GeoJSON 파일들 |
|
||||
|
||||
**워크플로우 6단계에 따른 파일 흐름:**
|
||||
```
|
||||
B03_FileInput/ (WF0: 파일 입력)
|
||||
└─ input/*.las, *.tif, *.prj, *.tfw
|
||||
↓
|
||||
B04_wf1_Surface/ (WF1: 지표면 분석)
|
||||
├─ processed/*.ply (변환된 포인트클라우드)
|
||||
└─ models/*.tif, *.geojson (DEM, TIN, 레이어)
|
||||
↓
|
||||
B05_wf2_Route/ (WF2: 경로 설계)
|
||||
└─ route/*.geojson (최적 경로)
|
||||
↓
|
||||
B06_wf3_ProfileCross/ (WF3: 종횡단 생성)
|
||||
├─ longitudinal/*.json (종단면)
|
||||
└─ cross_sections/*.json (횡단면 배열)
|
||||
↓
|
||||
B07_wf4_DesignDetail/ (WF4: 상세 설계)
|
||||
└─ structures/*.json (구조물 배치)
|
||||
↓
|
||||
B08_wf5_Quantity/ (WF5: 수량 산출)
|
||||
└─ quantities/*.json (수량 항목)
|
||||
↓
|
||||
B09_wf6_Estimation/ (WF6: 견적·문서)
|
||||
├─ v1/*.xlsx, *.pdf, *.dxf (최종 산출물 v1)
|
||||
├─ v2/*.xlsx, *.pdf, *.dxf (최종 산출물 v2)
|
||||
└─ bundles/*.zip (완전 패키지)
|
||||
```
|
||||
|
||||
**각 단계별 재실행:**
|
||||
- 예: B05 경로 설계 재실행 → `B05_wf2_Route/route/` 폴더만 초기화
|
||||
- 예: B09 문서 재생성 → `B09_wf6_Estimation/v3/` 폴더 생성 (v1, v2는 유지)
|
||||
|
||||
### 워크플로우 기반 폴더 구조의 이점
|
||||
|
||||
| 항목 | 이전 (raw/processed/computed) | 현재 (B04/B05/B06/.../B09) |
|
||||
|------|-----|------|
|
||||
| **직관성** | 계층적이나 추상적 | ✅ 워크플로우 단계와 정확히 일치 |
|
||||
| **단계 재실행** | 어느 폴더를 초기화할지 모호 | ✅ 해당 B0X 폴더만 초기화 |
|
||||
| **버전 관리** | outputs/v1, v2, ... 만 가능 | ✅ 모든 단계에서 가능 |
|
||||
| **파일 찾기** | raw/ 또는 processed/ 에서 검색 | ✅ B0X_Naming으로 명확함 |
|
||||
| **API 라우터** | 경로 결정이 복잡 | ✅ `f"storage/{company}/{user}/{project_id}/B{step}_*/..."` |
|
||||
|
||||
### 다음 단계:
|
||||
- [X] MariaDB에 위 테이블 생성 (18개 테이블)
|
||||
- [ ] 관계(FK) 설정
|
||||
- [ ] 인덱스 추가
|
||||
- [ ] Alembic 마이그레이션 코드 작성
|
||||
- [ ] Python ORM(SQLAlchemy) 모델 정의
|
||||
- [ ] 인덱스 추가 (경로 조회, 프로젝트별 필터 등)
|
||||
- [ ] Alembic 마이그레이션 코드 작성<<<<<< 확인 필요(mariaDB사용중)
|
||||
- [ ] Python ORM(SQLAlchemy) 모델 정의<<<<<< 확인 필요(mariaDB사용중)
|
||||
- [ ] API 라우터에서 파일 경로 저장 로직 추가
|
||||
- B03 파일 업로드 → `input_files.raw_file_path` 저장
|
||||
- B04 WF1 실행 → `surface_models.model_file_path`, `terrain_layers.layer_file_path` 저장
|
||||
- B05 WF2 실행 → `routes.route_data_path` 저장
|
||||
- B06 WF3 실행 → `longitudinal_sections.longitudinal_file_path`, `cross_sections.cross_section_file_path` 저장
|
||||
- B07 WF4 실행 → `structures.structure_data_path` 저장
|
||||
- B08 WF5 실행 → `quantity_items.quantity_data_path` 저장
|
||||
- B09 WF6 실행 → `outputs.outputs_directory_path`, `output_files.output_file_path` 저장
|
||||
+230
-344
@@ -1,388 +1,274 @@
|
||||
# 프로젝트 리팩토링 및 마이그레이션 계획서
|
||||
# B03~B06 기능 마이그레이션 계획서
|
||||
|
||||
**작성일:** 2026-07-05
|
||||
**상태:** 계획 수립 완료
|
||||
**목표:** 기존 POC(Proof of Concept) 코드를 정식 프로덕션 구조로 전환
|
||||
**갱신일:** 2026-07-05
|
||||
**상태:** 백엔드 마이그레이션 완료 — Stage 0~4 (B03~B06) 백엔드 이전 완료. 남은 항목은 프론트엔드(Vanilla TS) 재작성과 실제 DB 연동 검증.
|
||||
**범위:** `B03_FileInput` ~ `B06_wf3_ProfileCross`
|
||||
**DB:** MariaDB v10.6+ (aiomysql 드라이버, Raw SQL)
|
||||
|
||||
**진척 요약:**
|
||||
- Stage 0 (공통 기반): ✅ 완료
|
||||
- Stage 1 B03 (파일 입력): ✅ 백엔드 완료
|
||||
- Stage 2 B04 (지표면 분석): ✅ 백엔드 완료 (필터 4종 + 모델 5종 + 스무딩 + 등고선 + 파이프라인)
|
||||
- Stage 3 B05 (경로 설계): ✅ 백엔드 완료 (Dijkstra + ridge-valley + skeleton)
|
||||
- Stage 4 B06 (종횡단 생성): ✅ 백엔드 완료 (sampler + 종횡단 + Repository/Router)
|
||||
- 공통: 전 페이지 DB 접근을 aiomysql Raw SQL로 통일, 순수 계산부는 실데이터로 검증
|
||||
- 남은 작업: B04~B06 프론트엔드(Vanilla TS/WebCAD), 실제 aislo_db 연동·구형 수치 비교
|
||||
|
||||
---
|
||||
|
||||
## 1. 현황 분석 (Baseline Assessment)
|
||||
## 1. 확정 기준
|
||||
|
||||
### 1.1 기존 상태
|
||||
- **위치:** `0_old/` 폴더에 백업 완료
|
||||
- **제외 사항:** `venv/`, `node_modules/` 미포함 (별도 재설치 필요)
|
||||
- **특성:** 프로토타입 개발용 단순 구조
|
||||
|
||||
### 1.2 신 구조 목표
|
||||
- **거대 모듈 금지:** 기능별/페이지별 분할 자동화
|
||||
- **수직 통합 관리:** 프론트엔드(TypeScript) + 백엔드(Python) 페어링
|
||||
- **명확한 책임 분리:** 시스템 폴더 vs. 페이지 폴더 격리
|
||||
- 기능 동작의 기준 원본은 `0_old/main.py`와 `0_old/utils/`이다.
|
||||
- `0_old/backend/app/`은 이전 프로토타입 참고 자료이며 최종 동작 기준으로 사용하지 않는다.
|
||||
- 신규 배치 구조는 `.agent/structure.md`를 따른다. 구형 파일 구조는 승계하지 않는다.
|
||||
- 백엔드는 `.agent/backend.md`, 프론트엔드는 `.agent/frontend.md`를 따른다.
|
||||
- 저장 구조 및 DB 경로 열은 `.agent/db_schema_simple.md`의 단계별 파일시스템 구조를 따른다.
|
||||
- DB 스키마 작성·수정은 별도 담당 작업이다. 이 마이그레이션은 확정된 DB 계약만 `aiomysql`와 Raw SQL로 사용한다.
|
||||
- 루트 `main.py`에는 페이지 라우터 등록만 추가한다.
|
||||
- 구형 React/TSX UI는 복사하지 않고 현재 Vanilla TypeScript 구조와 공통 UI 템플릿에 맞게 재작성한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 신 디렉토리 구조 (New Structure)
|
||||
## 2. 작업 원칙
|
||||
|
||||
```
|
||||
프로젝트_루트/
|
||||
├── .agent/ # 에이전트 명세서
|
||||
│ ├── structure.md # 폴더/파일 명명규칙
|
||||
│ ├── agent.md # 기술스택 및 제약조건
|
||||
│ ├── backend.md # 백엔드 명세서
|
||||
│ ├── frontend.md # 프론트엔드 명세서
|
||||
│ └── migration_plan.md # 본 파일 (마이그레이션 가이드)
|
||||
│
|
||||
├── common_util/ # 공통 유틸리티 모듈
|
||||
│ ├── common_util_geometry.py # 기하학 연산 (Trimesh, Shapely)
|
||||
│ ├── common_util_gis.py # GIS 유틸 (PostGIS, Geopandas)
|
||||
│ ├── common_util_file.py # 파일 I/O 및 경로 관리
|
||||
│ ├── common_util_validation.py # 데이터 검증 (Pydantic)
|
||||
│ ├── common_util_math.py # 수학/통계 함수
|
||||
│ └── common_util_string.ts # 문자열 처리 (TypeScript)
|
||||
│
|
||||
├── db_management/ # DB 스키마 및 마이그레이션
|
||||
│ ├── schema.sql # PostgreSQL + PostGIS 초기 스키마
|
||||
│ ├── migration_001_init.py # 마이그레이션 스크립트 v1
|
||||
│ └── README.md # DB 운영 가이드
|
||||
│
|
||||
├── resources/ # UI 리소스 (로고, 이미지, 폰트)
|
||||
│ ├── images/
|
||||
│ │ ├── logo.svg
|
||||
│ │ ├── favicon.ico
|
||||
│ │ └── banners/
|
||||
│ └── fonts/
|
||||
│ └── noto-sans-kr.woff2
|
||||
│
|
||||
├── storage/ # 영구 저장소 (물리 파일)
|
||||
│ └── README.md # 저장소 구조 설명
|
||||
│
|
||||
├── ui_template/ # 공통 UI 템플릿 및 테마
|
||||
│ ├── ui_template_theme.css # 글로벌 색상/폰트 변수
|
||||
│ ├── ui_template_locale.ts # 다국어 관리 (i18n)
|
||||
│ ├── ui_template_elements.ts # 공통 컴포넌트 정의
|
||||
│ └── ui_template_styles.css # 기본 컴포넌트 스타일
|
||||
│
|
||||
├── templates/ # 보고서/도면 템플릿
|
||||
│ ├── template_report_standard.xlsx # 표준 보고서
|
||||
│ ├── template_drawing_base.dwg # CAD 기본 도면
|
||||
│ └── template_quantity_sheet.xlsx # 수량 산출서
|
||||
│
|
||||
├── config/ # 환경 설정
|
||||
│ ├── config_system.py # 백엔드 파라미터
|
||||
│ ├── config_db.py # DB 연결 설정
|
||||
│ ├── config_frontend.ts # 프론트엔드 상수
|
||||
│ └── .env.example # 환경 변수 템플릿
|
||||
│
|
||||
├── A01_Home/ # 로그인 전 01: 홈
|
||||
│ ├── A01_Home_UI_Page.ts # 페이지 UI 컴포넌트
|
||||
│ ├── A01_Home_UI_Style.css # 페이지 스타일
|
||||
│ ├── A01_Home_Api_Fetch.py # API 라우터
|
||||
│ └── A01_Home_Schema.py # Pydantic 스키마
|
||||
│
|
||||
├── A02_ProgDetail/ ... A08_Support/ # 로그인 전 페이지들 (동일 구조)
|
||||
│
|
||||
├── B01_AccountDetail/ # 로그인 후 01: 계정
|
||||
├── B02_ProjRegister/ # 로그인 후 02: 프로젝트 등록
|
||||
├── B03_FileInput/ # 로그인 후 03: 파일 입력
|
||||
│
|
||||
├── B04_wf1_Surface/ # Workflow 1: 지표면 분석
|
||||
│ ├── B04_wf1_Surface_UI_View.ts # UI 뷰어
|
||||
│ ├── B04_wf1_Surface_UI_Form.ts # 입력 폼
|
||||
│ ├── B04_wf1_Surface_UI_Style.css
|
||||
│ ├── B04_wf1_Surface_Api_Router.py # API 라우터
|
||||
│ ├── B04_wf1_Surface_Engine.py # 연산 엔진 (Trimesh, PostGIS)
|
||||
│ └── B04_wf1_Surface_Schema.py # 데이터 스키마
|
||||
│
|
||||
├── B05_wf2_Route/ ... B09_wf6_Estimation/ # 워크플로우 2-6 (동일 구조)
|
||||
│
|
||||
├── B10_Payment/ & B11_Status/ # 로그인 후 최종 페이지
|
||||
│
|
||||
├── main.py # FastAPI 애플리케이션 진입점
|
||||
├── pyproject.toml # Python 의존성 (Poetry)
|
||||
├── package.json & package-lock.json # Node.js 의존성
|
||||
├── CLAUDE.md # 프로젝트 기본 정보
|
||||
├── README.md # 프로젝트 개요
|
||||
└── .gitignore # Git 제외 목록
|
||||
### 2.1 함수 하나씩 이전
|
||||
|
||||
각 작업 단위는 다음 순서를 지킨다.
|
||||
|
||||
1. 대상 구형 함수와 직접 의존성 확인
|
||||
2. 신규 페이지 폴더에 책임에 맞는 파일 하나 생성
|
||||
3. 함수 하나만 이전·수정
|
||||
4. 해당 함수 단위 테스트 또는 최소 실행 검증
|
||||
5. 포맷터·린터 실행
|
||||
6. 검증 완료 후 다음 함수 진행
|
||||
|
||||
한 번에 구형 모듈 전체를 복사하지 않는다. 구형 전역 경로, 동기식 파일 처리, 하드코딩 설정은 그대로 가져오지 않는다.
|
||||
|
||||
### 2.2 파일 분리
|
||||
|
||||
- `*_Router.py`: FastAPI 엔드포인트와 응답 변환
|
||||
- `*_Schema.py`: Pydantic 요청·응답 검증
|
||||
- `*_Engine_*.py`: 페이지 고유 연산
|
||||
- `*_Repository.py`: `asyncpg` Raw SQL과 DB 경로 메타데이터 처리
|
||||
- `common_util/`: 둘 이상의 페이지가 실제로 공유하는 경로·원자적 파일 쓰기·기하 유틸만 배치
|
||||
- 단일 파일이 700줄에 도달하기 전에 기능별 파일 분할을 제안하고 승인 후 `structure.md`를 갱신한다.
|
||||
|
||||
### 2.3 저장소 경계
|
||||
|
||||
```text
|
||||
storage/{company_slug}/{user_slug}/{project_id}/
|
||||
├── B03_FileInput/
|
||||
├── B04_wf1_Surface/
|
||||
├── B05_wf2_Route/
|
||||
└── B06_wf3_ProfileCross/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 마이그레이션 단계별 계획 (Phased Migration)
|
||||
|
||||
### Phase 1: 기초 인프라 (Infrastructure Setup) - 1-2주
|
||||
**담당:** 전체
|
||||
**목표:** 설정 및 공통 유틸 정리
|
||||
|
||||
#### 1.1 설정 파일 정리
|
||||
- [ ] `config_system.py` 작성
|
||||
- FastAPI 포트, 로깅 레벨, 알고리즘 파라미터
|
||||
- 예: `MESH_GRID_SIZE`, `UPLOAD_MAX_MB`, `LOG_LEVEL`
|
||||
- [ ] `config_db.py` 작성
|
||||
- PostgreSQL 연결 문자열 (asyncpg)
|
||||
- PostGIS 테이블 정의 위치
|
||||
- [ ] `config_frontend.ts` 작성
|
||||
- API 기본 URL, 파일 업로드 제한 크기
|
||||
- WebCAD 렌더링 옵션
|
||||
|
||||
#### 1.2 공통 유틸 마이그레이션
|
||||
- [ ] **0_old/** 에서 기존 유틸 함수 추출
|
||||
- [ ] `common_util/` 내 분류:
|
||||
- `common_util_geometry.py` ← Trimesh, Shapely 관련
|
||||
- `common_util_gis.py` ← PostGIS 쿼리, Geopandas 변환
|
||||
- `common_util_file.py` ← LAS, DEM, DWG 읽기/쓰기
|
||||
- `common_util_validation.py` ← Pydantic 모델, 데이터 검증
|
||||
|
||||
#### 1.3 UI 템플릿 기초 설정
|
||||
- [ ] `ui_template_theme.css` 작성
|
||||
- CSS 변수 정의 (라이트/다크 모드)
|
||||
- 예: `--color-bg`, `--color-text`, `--color-primary`
|
||||
- [ ] `ui_template_locale.ts` 작성
|
||||
- 한글 / 영문 키-값 쌍
|
||||
- 예: `A01_Home_Title: ["반갑습니다", "Welcome"]`
|
||||
- [ ] `ui_template_elements.ts` 작성
|
||||
- 공통 버튼, 입력창, 카드 컴포넌트 정의
|
||||
|
||||
#### 1.4 DB 스키마 및 마이그레이션 준비
|
||||
- [ ] `db_management/schema.sql` 작성
|
||||
- 사용자, 프로젝트, 파일 정보 테이블
|
||||
- PostGIS 기하학 타입 (geometry, raster)
|
||||
- [ ] `db_management/migration_001_init.py` 작성
|
||||
- asyncpg 기반 초기화 스크립트
|
||||
|
||||
#### 1.5 FastAPI 메인 애플리케이션 구조
|
||||
- [ ] `main.py` 작성 (라우터 등록만)
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
# A01_Home, B04_wf1_Surface 등에서 라우터 import
|
||||
# app.include_router(a01_router, prefix="/api/a01")
|
||||
```
|
||||
각 페이지는 자기 폴더만 직접 생성·수정한다. 상위 단계 변경 시 하위 결과를 임의 삭제하지 않고 `stale_from`을 갱신한다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 로그인 전 페이지 (Pre-Login Pages) - 2-3주
|
||||
**담당:** 프론트엔드 + 백엔드 (페이지별 페어)
|
||||
**목표:** A01~A08 페이지 완성
|
||||
## 3. 구형 기능 분석 결과
|
||||
|
||||
#### 2.1 페이지별 구조 (A01_Home 예시)
|
||||
각 페이지는 다음 파일 구성:
|
||||
### 3.1 B03_FileInput
|
||||
|
||||
**프론트엔드 (TypeScript)**
|
||||
- `A01_Home_UI_Page.ts` ← 페이지 진입점 + 컴포넌트 조립
|
||||
- `A01_Home_UI_Style.css` ← 페이지별 커스텀 스타일
|
||||
구형 원본:
|
||||
|
||||
**백엔드 (Python)**
|
||||
- `A01_Home_Api_Fetch.py` ← FastAPI 라우터 (`/api/a01/...`)
|
||||
- `A01_Home_Schema.py` ← Pydantic 요청/응답 모델
|
||||
- `0_old/main.py`: `check_sample`, `upload_files`
|
||||
- 보조 참고: `0_old/backend/app/analyzer.py`의 파일 분석 함수
|
||||
|
||||
#### 2.2 A01_Home (홈) - Week 1
|
||||
- [ ] `A01_Home_UI_Page.ts` 작성
|
||||
- 로고, 최신 소식 패널, 사이트맵 링크
|
||||
- [ ] `A01_Home_Api_Fetch.py` 작성
|
||||
- `GET /api/a01/news` ← 최신 소식 조회
|
||||
- [ ] 기본 스타일 적용
|
||||
책임:
|
||||
|
||||
#### 2.3 A02_ProgDetail ~ A08_Support - Week 2-3
|
||||
동일 구조로 순차 진행:
|
||||
- [ ] A02_ProgDetail (프로그램 상세)
|
||||
- [ ] A03_CompDetail (회사 상세)
|
||||
- [ ] A04_NewsHistory (뉴스/이력)
|
||||
- [ ] A05_EduDetail (교육)
|
||||
- [ ] A06_Login (로그인) ← **핵심: JWT 토큰 발급**
|
||||
- [ ] A07_Register (회원가입) ← **핵심: 사용자 생성, 이메일 검증**
|
||||
- [ ] A08_Support (기술 지원)
|
||||
- LAS/TIF/TFW/PRJ/DXF 업로드 검증
|
||||
- `B03_FileInput/input/{file_type}/` 저장
|
||||
- 파일 메타데이터 추출 및 `input_files` 기록
|
||||
- 업로드 완료 상태 반환
|
||||
|
||||
B04로 넘길 기능:
|
||||
|
||||
- `process_pipeline`
|
||||
- `run_ground_analysis`
|
||||
- 포인트 필터링 및 지표면 모델 생성
|
||||
|
||||
### 3.2 B04_wf1_Surface
|
||||
|
||||
구형 원본:
|
||||
|
||||
- `0_old/main.py`: `process_pipeline`, `ensure_terrain_models`, 지표면·포인트 API
|
||||
- `0_old/utils/structurizer.py`
|
||||
- `filter_grid_min_z.py`, `filter_csf.py`, `filter_pmf.py`, `filter_ransac.py`
|
||||
- `terrain_model_converter.py`, `surface_smoother.py`, `contour_extractor.py`
|
||||
|
||||
책임:
|
||||
|
||||
- LAS 구조화와 지면 필터 생성
|
||||
- 변환 포인트클라우드 저장
|
||||
- TIN/DTM/NURBS/implicit/meshfree 모델 생성
|
||||
- 스무딩, 미리보기, 등고선 생성
|
||||
- 지표면 모델 선택 확정 및 하위 단계 stale 전파
|
||||
|
||||
### 3.3 B05_wf2_Route
|
||||
|
||||
구형 원본:
|
||||
|
||||
- `0_old/main.py`: 경로점 CRUD, solve/result/confirm, stale 서명 처리
|
||||
- `0_old/utils/route_solver.py`
|
||||
- `0_old/utils/route_solver_ridgevalley.py`
|
||||
- `0_old/utils/terrain_skeleton.py`
|
||||
|
||||
책임:
|
||||
|
||||
- BP/EP/CP/AP/FP 입력 검증과 저장
|
||||
- Dijkstra 및 ridge-valley 경로 계산
|
||||
- 경사·곡선반경·회피/금지 구역 검증
|
||||
- 경로 결과 조회·확정 및 B06 stale 전파
|
||||
|
||||
### 3.4 B06_wf3_ProfileCross
|
||||
|
||||
구형 원본:
|
||||
|
||||
- `0_old/main.py`: sections generate/get/confirm 및 입력 서명 처리
|
||||
- `0_old/utils/surface_elevation_sampler.py`
|
||||
- `0_old/utils/section_generator.py`
|
||||
|
||||
책임:
|
||||
|
||||
- 확정 경로와 확정 지표면 모델 검증
|
||||
- 종단 표고 프로필 생성
|
||||
- 지정 측점 간격의 횡단면 생성
|
||||
- 결과 조회·확정 및 재현성 서명 관리
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 로그인 후 기초 페이지 (Post-Login Basic Pages) - 1-2주
|
||||
**담당:** 프론트엔드 + 백엔드
|
||||
**목표:** B01~B03 페이지 완성
|
||||
## 4. 함수 단위 실행 순서
|
||||
|
||||
#### 3.1 B01_AccountDetail (계정 상세)
|
||||
- [ ] 사용자 정보 조회/수정
|
||||
- [ ] 구독 상태, 다운로드 이력
|
||||
### Stage 0 — 공통 기반
|
||||
|
||||
#### 3.2 B02_ProjRegister (프로젝트 등록)
|
||||
- [ ] 프로젝트 생성 폼
|
||||
- [ ] 고객사명 / 사용자명 / 프로젝트ID 관리
|
||||
- [ ] **핵심:** `storage/[고객사]/[사용자]/[프로젝트ID]/` 자동 생성
|
||||
- [x] `config_system.py`의 저장 경로에서 불필요한 `projects` 경로 제거
|
||||
- [x] 프로젝트 루트와 B03~B06 단계별 경로를 안전하게 만드는 공통 경로 함수 추가
|
||||
- [x] JSON 원자적 쓰기 함수 이전
|
||||
- [x] `workflow.json` 읽기 및 stale 필드 원자적 갱신 함수 이전
|
||||
- [x] 공통 함수별 테스트 작성
|
||||
|
||||
#### 3.3 B03_FileInput (파일 입력)
|
||||
- [ ] 파일 업로드 UI (Drag & Drop)
|
||||
- [ ] LAS, DEM, DWG 파일 검증
|
||||
- [ ] `storage/` 경로에 물리 저장
|
||||
### Stage 1 — B03_FileInput
|
||||
|
||||
- [x] `B03_FileInput_Schema.py`: 파일 종류·크기·확장자 검증 모델
|
||||
- [x] `B03_FileInput_Engine.py`: 안전한 파일명과 목적 경로 결정 함수
|
||||
- [x] `B03_FileInput_Engine.py`: 업로드 스트림 저장 함수
|
||||
- [x] `B03_FileInput_Engine_Analyze.py`: LAS 메타데이터 분석 함수
|
||||
- [x] `B03_FileInput_Engine_Analyze.py`: PRJ/TIF/TFW 메타데이터 분석 함수
|
||||
- [x] `B03_FileInput_Repository.py`: `input_files` 저장 함수
|
||||
- [x] `B03_FileInput_Router.py`: 업로드 엔드포인트
|
||||
- [x] B03 프론트 드롭존·목록·검증·로딩 UI를 공통 컴포넌트 기반으로 작성
|
||||
- [x] 업로드 통합 테스트
|
||||
|
||||
### Stage 2 — B04_wf1_Surface
|
||||
|
||||
- [x] LAS 구조화 함수 이전
|
||||
- [x] grid-min-z 필터 함수 이전 및 검증
|
||||
- [x] CSF 필터 함수 이전 및 검증
|
||||
- [x] PMF 필터 함수 이전 및 검증
|
||||
- [x] RANSAC 필터 함수 이전 및 검증
|
||||
- [x] 지표면 모델 공통 컨텍스트 생성 함수 이전 (ModelContext + 메시 유틸)
|
||||
- [x] TIN 생성 함수 이전
|
||||
- [x] DTM 생성 함수 이전
|
||||
- [x] NURBS 생성 함수 이전
|
||||
- [x] implicit 생성 함수 이전
|
||||
- [x] meshfree 생성 함수 이전
|
||||
- [x] DTM/TIN 스무딩 함수 이전
|
||||
- [x] 등고선 생성 함수 이전
|
||||
- [x] 미리보기·메타데이터·모델 확정 라우트 추가 (analyze/models 라우트 + 파이프라인 manifest)
|
||||
- [x] `processed_point_cloud`, `surface_models`, `terrain_layers` Raw SQL 저장 함수 추가 (aiomysql)
|
||||
- [ ] B04 폼·WebCAD 뷰어를 Vanilla TypeScript로 재작성
|
||||
- [ ] 샘플 LAS 결과를 구형 출력과 비교 검증
|
||||
|
||||
### Stage 3 — B05_wf2_Route
|
||||
|
||||
- [x] 경로점 Pydantic 모델 및 유효성 검사 이전 (Schema)
|
||||
- [x] 경로점 저장·조회·초기화 함수 이전 (Repository)
|
||||
- [x] 비용면 생성 및 캐시 함수 이전 (Solver)
|
||||
- [x] Dijkstra 단일 구간 탐색 함수 이전 (Geometry)
|
||||
- [x] 최적 경로 조립 함수 이전 (Solver solve_optimal_route)
|
||||
- [x] 지형 skeleton 생성 함수 이전 (Skeleton, D8 흐름누적)
|
||||
- [x] ridge-valley 탐색 함수 이전 (RidgeValley, 정속경사+fillet)
|
||||
- [ ] 입력 서명·stale 판정 함수 이전
|
||||
- [x] 경로 확정 함수 이전 (Repository confirm_route + confirm 라우트)
|
||||
- [x] `routes`, `route_points`, `route_statistics` Raw SQL 저장 함수 추가 (aiomysql)
|
||||
- [ ] B05 경로 편집·제약조건·결과 뷰어를 Vanilla TypeScript로 재작성
|
||||
- [x] 기존 route solver 테스트를 신규 구조로 이관·통과
|
||||
|
||||
### Stage 4 — B06_wf3_ProfileCross
|
||||
|
||||
- [x] 지표면 sampler 인터페이스 이전 (Engine_Sampler: SurfaceElevationSampler Protocol)
|
||||
- [x] DTM grid sampler 함수 이전 (DtmGridSampler, 4-꼭짓점 valid 검증)
|
||||
- [x] 보간 sampler 함수 이전 (InterpolatedSurfaceSampler + build_surface_sampler 5종)
|
||||
- [x] 측점 배열 생성 함수 이전 (Engine_Section: _chainages)
|
||||
- [x] 경로 보간 및 접선 계산 함수 이전 (_interpolate_xy, _tangent_at)
|
||||
- [x] 종단·횡단 생성 함수 이전 (generate_sections)
|
||||
- [ ] 입력 서명·중복 실행 방지 함수 이전 (delete_sections_for_route로 멱등 재실행만 구현)
|
||||
- [x] 결과 조회·확정 라우트 추가 (sections/generate·get·confirm 라우트)
|
||||
- [x] `longitudinal_sections`, `cross_sections` Raw SQL 저장 함수 추가 (aiomysql Repository)
|
||||
- [ ] B06 종단도·횡단 카드 UI를 Vanilla TypeScript로 재작성
|
||||
- [ ] 구형 section 결과와 수치 비교 검증
|
||||
|
||||
**B06 백엔드 구현 파일:**
|
||||
- `B06_wf3_ProfileCross_Engine_Sampler.py` — 표고 sampler (Protocol + DTM/보간 5종)
|
||||
- `B06_wf3_ProfileCross_Engine_Section.py` — 종횡단 생성 (측점/접선/횡단 샘플)
|
||||
- `B06_wf3_ProfileCross_Engine.py` — 오케스트레이터 (경로 GeoJSON→종횡단→파일 저장)
|
||||
- `B06_wf3_ProfileCross_Repository.py` — aiomysql Raw SQL (2 테이블)
|
||||
- `B06_wf3_ProfileCross_Schema.py` / `_Router.py` — Pydantic + FastAPI 라우트
|
||||
|
||||
**검증 상태:** B04(DTM)→B05(경로 70.71m)→B06(종단면+횡단면 5건) end-to-end 파일 생성 확인.
|
||||
DB 저장 함수는 FakeCursor 기반 문법 검증만 수행 (실제 aislo_db 연동은 나스 서버 준비 후).
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Workflow 엔진 (1-4) - 4-6주
|
||||
**담당:** 백엔드 주 (프론트엔드 보조)
|
||||
**목표:** B04~B07 워크플로우 엔진 완성
|
||||
## 5. API 및 상태 원칙
|
||||
|
||||
#### 4.1 B04_wf1_Surface (지표면 분석) - Week 1-2
|
||||
**구조:**
|
||||
```
|
||||
B04_wf1_Surface/
|
||||
├── B04_wf1_Surface_UI_View.ts # WebCAD 뷰어
|
||||
├── B04_wf1_Surface_UI_Form.ts # 입력 폼 (좌측 패널)
|
||||
├── B04_wf1_Surface_UI_Style.css
|
||||
├── B04_wf1_Surface_Api_Router.py # GET/POST 엔드포인트
|
||||
├── B04_wf1_Surface_Engine.py # Trimesh 메쉬 생성 로직
|
||||
└── B04_wf1_Surface_Schema.py # Pydantic 모델
|
||||
```
|
||||
|
||||
**구현:**
|
||||
- [ ] LAS 포인트클라우드 → Trimesh 메쉬 변환
|
||||
- [ ] 메쉬 좌표 직렬화 (JSON)
|
||||
- [ ] WebGL 캔버스에서 렌더링
|
||||
- [ ] 메쉬 정보 DB 저장 (경로만)
|
||||
|
||||
#### 4.2 B05_wf2_Route (경로 설계) - Week 2-3
|
||||
- [ ] 지도 UI (2D 평면도)
|
||||
- [ ] 드래그로 경로 그리기
|
||||
- [ ] Geopandas + PostGIS 거리 계산
|
||||
- [ ] 경로 점 배열 저장
|
||||
|
||||
#### 4.3 B06_wf3_ProfileCross (종횡단) - Week 3-4
|
||||
- [ ] 경로 기반 고도 프로필 생성
|
||||
- [ ] 종단도 + 횡단도 계산 (PostGIS)
|
||||
- [ ] 기울기, 높이차 자동 산출
|
||||
|
||||
#### 4.4 B07_wf4_DesignDetail (상세 설계) - Week 4-5
|
||||
- [ ] 도로 포장 두께, 기울기 입력
|
||||
- [ ] Whitebox 토양 분석
|
||||
- [ ] 3D 설계 도면 생성
|
||||
- API 경로는 `/api/projects/{project_id}/...` 형태를 유지하되 페이지별 Router에서 선언한다.
|
||||
- 모든 JSON 요청은 Pydantic 검증 후 Engine으로 전달한다.
|
||||
- 긴 지형 연산은 이벤트 루프를 막지 않도록 실행 방식을 별도 검토한다.
|
||||
- 오류 응답은 `{"status": "error", "message": "원인"}` 형식을 유지한다.
|
||||
- 프론트 API 호출은 로딩 Overlay를 항상 시작·해제한다.
|
||||
- UI 문자열은 `ui_template_locale.ts`에 먼저 등록한 뒤 참조한다.
|
||||
- 상위 단계 재계산 시 하위 결과는 stale 처리하며 다른 페이지 폴더를 직접 삭제하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: 워크플로우 후반 (5-6) + 최종 페이지 - 2-3주
|
||||
**담당:** 백엔드 주 + 프론트엔드
|
||||
**목표:** B08~B11 완성
|
||||
## 6. 검증 체크리스트
|
||||
|
||||
#### 5.1 B08_wf5_Quantity (수량 산출)
|
||||
- [ ] 포장재, 흙 깍기/쌓기 수량 계산
|
||||
- [ ] Excel 템플릿 자동 채우기
|
||||
각 함수 이전 후:
|
||||
|
||||
#### 5.2 B09_wf6_Estimation (견적/문서)
|
||||
- [ ] 단가 × 수량 = 금액 계산
|
||||
- [ ] PDF 보고서 생성 (Jinja2 템플릿)
|
||||
- [ ] DWG 도면 생성 (pyDWG)
|
||||
- [ ] 구형 함수의 입력·출력 계약 비교
|
||||
- [ ] 하드코딩 설정 제거 및 `config_system.py` 연결
|
||||
- [ ] 신규 단계별 저장 경로 사용 확인
|
||||
- [ ] 경로 이탈 및 파일명 공격 방지 확인
|
||||
- [ ] 예외 처리와 표준 오류 응답 확인
|
||||
- [ ] Python: `ruff format`, `ruff check --fix`
|
||||
- [ ] TypeScript/CSS: `npx prettier --write`
|
||||
- [ ] 단위 테스트 실행
|
||||
- [ ] 관련 통합 테스트 실행
|
||||
- [ ] 단일 파일 700줄 미만 확인
|
||||
|
||||
#### 5.3 B10_Payment (결재)
|
||||
- [ ] 견적서 검토 및 승인
|
||||
- [ ] 결재자 서명 프로세스
|
||||
단계 완료 후:
|
||||
|
||||
#### 5.4 B11_Status (상태/다운로드)
|
||||
- [ ] 프로젝트 상태 조회
|
||||
- [ ] 최종 산출물 다운로드
|
||||
- [ ] B03 업로드 파일과 DB 메타데이터 일치
|
||||
- [ ] B04 지표면 모델의 범위·점 수·표고 통계 비교
|
||||
- [ ] B05 경로 길이·경사·곡선반경·제약 위반 비교
|
||||
- [ ] B06 측점 수·종단 표고·횡단 좌표 비교
|
||||
- [ ] 다중 브라우저 polling 및 stale 전파 확인
|
||||
|
||||
---
|
||||
|
||||
## 4. 기술 구현 가이드 (Technical Guidelines)
|
||||
## 7. 작업 경계와 보류 항목
|
||||
|
||||
### 4.1 백엔드 파이썬 스타일
|
||||
```python
|
||||
# config 반드시 import
|
||||
from config.config_system import MESH_GRID_SIZE
|
||||
from config.config_db import DB_URL
|
||||
|
||||
# 공통 유틸 사용
|
||||
from common_util.common_util_geometry import create_mesh_from_points
|
||||
from common_util.common_util_validation import validate_project_id
|
||||
|
||||
# Raw SQL (ORM 금지)
|
||||
query = "SELECT ST_Volume(geom) FROM surfaces WHERE project_id = $1"
|
||||
result = await db_pool.fetchval(query, project_id)
|
||||
|
||||
# 에러 처리
|
||||
try:
|
||||
mesh = create_mesh(points)
|
||||
except ValueError as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
```
|
||||
|
||||
### 4.2 프론트엔드 TypeScript 스타일
|
||||
```typescript
|
||||
// CSS 변수 사용 (색상 하드코딩 금지)
|
||||
const styles = `
|
||||
.panel {
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
`;
|
||||
|
||||
// 다국어 (i18n 필수)
|
||||
const title = ui_locales.A01_Home_Title[currentLanguageIndex];
|
||||
|
||||
// 이벤트 핸들러 명명법
|
||||
function onB04_Surface_Calculate_Click() { ... }
|
||||
|
||||
// 로딩 스피너 (API 호출 시 필수)
|
||||
showLoadingOverlay();
|
||||
const result = await fetchAPI("/api/b04/calculate", data);
|
||||
hideLoadingOverlay();
|
||||
```
|
||||
|
||||
### 4.3 파일 정보 저장 규칙
|
||||
- **DB에 저장:** 메타데이터만
|
||||
- 파일명, 원본 경로, 업로드 시간, 파일 크기
|
||||
- **물리 저장:** `storage/[고객사]/[사용자]/[프로젝트ID]/`
|
||||
- LAS, DEM, 중간 계산 파일, 최종 메쉬
|
||||
- DB 테이블·FK·인덱스 정의 변경은 본 작업에서 수행하지 않는다.
|
||||
- `.agent/db_schema_simple.md`는 DB 담당자의 작업 영역이므로 직접 수정하지 않는다.
|
||||
- DB 스키마가 확정되지 않은 Repository 함수는 인터페이스만 계획하고 임의 열을 만들지 않는다.
|
||||
- B07 이후 기능은 이번 마이그레이션 범위에서 제외한다.
|
||||
- 구형 샘플 데이터는 검증 입력으로만 사용하고 신규 영구저장소로 자동 복사하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 의존성 (Dependencies)
|
||||
## 8. 다음 실행 단위
|
||||
|
||||
### Python 라이브러리
|
||||
```
|
||||
fastapi==0.104.1
|
||||
pydantic==2.5.0
|
||||
asyncpg==0.29.0
|
||||
trimesh==3.23.0
|
||||
geopandas==0.14.0
|
||||
shapely==2.0.1
|
||||
rasterio==1.3.8
|
||||
laspy==2.4.1
|
||||
whitebox==2.3.0
|
||||
python-multipart==0.0.6
|
||||
jinja2==3.1.2
|
||||
pydwg==0.2.0
|
||||
```
|
||||
|
||||
### Node.js 라이브러리
|
||||
```
|
||||
typescript@5.x
|
||||
react@18.x (또는 vanilla TS)
|
||||
webpack@5.x
|
||||
tailwindcss@3.x (또는 커스텀 CSS)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 체크리스트 (Checklist)
|
||||
|
||||
### 시작 전 (Pre-Migration)
|
||||
- [ ] `0_old/` 백업 확인
|
||||
- [ ] git 커밋 (`"Refactor: Start migration to new structure"`)
|
||||
- [ ] 모든 폴더 생성 완료 (Phase 1에서 수행)
|
||||
|
||||
### 각 Phase별 마무리
|
||||
- [ ] 코드 포맷팅 (`ruff format`, `prettier --write`)
|
||||
- [ ] 린터 체크 (`ruff check`)
|
||||
- [ ] 단위 테스트 작성
|
||||
- [ ] git 커밋 (Phase별로 구분)
|
||||
|
||||
### 최종 (Post-Migration)
|
||||
- [ ] 전체 시스템 통합 테스트
|
||||
- [ ] 프로덕션 배포 (Docker)
|
||||
- [ ] 사용자 승인
|
||||
|
||||
---
|
||||
|
||||
## 7. 참고 자료
|
||||
|
||||
- **폴더 구조:** `.agent/structure.md`
|
||||
- **백엔드 명세:** `.agent/backend.md`
|
||||
- **프론트엔드 명세:** `.agent/frontend.md`
|
||||
- **기술 스택:** `.agent/agent.md`
|
||||
|
||||
---
|
||||
|
||||
**다음 단계:** Phase 1 시작 → `config/` 및 `common_util/` 작성
|
||||
첫 구현은 Stage 0의 저장 경로 함수 하나로 시작한다. 함수와 테스트를 검증한 뒤 다음 공통 함수로 이동한다.
|
||||
|
||||
+13
-2
@@ -26,6 +26,9 @@ my-project/
|
||||
├── common_util/ # 공통 유틸리티 (공통 기능 코드)
|
||||
│ ├── common_util_validate.ts # 프론트 1차 유효성 검사 (이메일/빈값/최소길이)
|
||||
│ ├── common_util_string.ts
|
||||
│ ├── common_util_storage.py # 프로젝트 및 워크플로우 단계별 저장 경로
|
||||
│ ├── common_util_json.py # JSON 원자적 저장
|
||||
│ ├── common_util_workflow.py # 공유 workflow.json 상태 처리
|
||||
│ └── common_util_format.py
|
||||
├── db_management/ # DB 관리 폴더 (PostgreSQL 마이그레이션 및 스키마 관리)
|
||||
│ ├── schema.sql
|
||||
@@ -86,9 +89,17 @@ my-project/
|
||||
│ └── B02_ProjRegister_UI_Style.css
|
||||
├── B03_FileInput/ # 로그인 후 03: 파일 입력 (헤더+준비중 안내)
|
||||
│ ├── B03_FileInput_UI_Page.ts
|
||||
│ └── B03_FileInput_UI_Style.css
|
||||
│ ├── B03_FileInput_UI_Style.css
|
||||
│ ├── B03_FileInput_Api_Fetch.ts # 다중 파일 업로드 API 클라이언트
|
||||
│ ├── B03_FileInput_Schema.py # 업로드 파일 Pydantic 검증
|
||||
│ ├── B03_FileInput_Engine.py # 업로드 저장 경로·스트림 처리
|
||||
│ ├── B03_FileInput_Engine_Analyze.py # 원본 파일 메타데이터 분석
|
||||
│ ├── B03_FileInput_Repository.py # input_files asyncpg Raw SQL
|
||||
│ └── B03_FileInput_Router.py # 다중 파일 업로드 API
|
||||
├── B04_wf1_Surface/ # 로그인 후 04: 1차 workflow (지표면 모델 분석) — 워크플로우 셸
|
||||
│ └── B04_wf1_Surface_UI_Page.ts
|
||||
│ ├── B04_wf1_Surface_UI_Page.ts
|
||||
│ ├── B04_wf1_Surface_Engine_Structurize.py # LAS/LAZ 구조화
|
||||
│ └── B04_wf1_Surface_Engine_Filter_Grid.py # grid minimum-Z 필터
|
||||
├── B05_wf2_Route/ # 로그인 후 05: 2차 workflow (경로설계) — 워크플로우 셸
|
||||
│ └── B05_wf2_Route_UI_Page.ts
|
||||
├── B06_wf3_ProfileCross/ # 로그인 후 06: 3차 workflow (종횡단 생성) — 워크플로우 셸
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(./venv/Scripts/python.exe -m unittest discover -s tests -p \"test_b03_file_input_router.py\" -v)",
|
||||
"Bash(./venv/Scripts/python.exe -m unittest discover -s tests -p \"test_*.py\")",
|
||||
"Bash(./venv/Scripts/python.exe -c \"from config import config_db; from B03_FileInput import B03_FileInput_Repository, B03_FileInput_Router; print\\('imports OK'\\)\")",
|
||||
"Bash(./venv/Scripts/python.exe -m ruff format config/config_db.py config/config_system.py B03_FileInput/B03_FileInput_Repository.py B03_FileInput/B03_FileInput_Router.py tests/test_b03_file_input_repository.py tests/test_b03_file_input_router.py)",
|
||||
"Bash(ruff format *)",
|
||||
"Bash(ruff check *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,12 @@
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebSearch",
|
||||
"Bash(node_modules/.bin/tsc --noEmit)"
|
||||
"Bash(node_modules/.bin/tsc --noEmit)",
|
||||
"Bash(./venv/Scripts/python.exe -m unittest discover -s tests -p \"test_*.py\")",
|
||||
"Bash(ruff format *)",
|
||||
"Bash(ruff check *)",
|
||||
"Bash(./venv/Scripts/python.exe -m unittest discover -s tests -p \"test_b04_surface_filter_pmf.py\" -v)",
|
||||
"Bash(./venv/Scripts/python.exe -m unittest discover -s tests -p \"test_b04_surface_filter_ransac.py\" -v)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
+11
-6
@@ -4,12 +4,12 @@ SERVER_PORT=8000
|
||||
DEBUG=False
|
||||
ENVIRONMENT=development
|
||||
|
||||
# 데이터베이스
|
||||
# 데이터베이스 (MariaDB)
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME=forest_road
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_PORT=3306
|
||||
DB_NAME=aislo_db
|
||||
DB_USER=aislo
|
||||
DB_PASSWORD=aislo
|
||||
DB_POOL_MIN=5
|
||||
DB_POOL_MAX=20
|
||||
|
||||
@@ -18,11 +18,16 @@ LOG_LEVEL=INFO
|
||||
|
||||
# 파일 업로드
|
||||
UPLOAD_MAX_MB=500
|
||||
UPLOAD_MAX_FILES=20
|
||||
UPLOAD_CHUNK_SIZE_BYTES=1048576
|
||||
|
||||
# 지형 분석 파라미터
|
||||
MESH_GRID_SIZE=1.0
|
||||
MESH_SMOOTHING_ITERATIONS=0
|
||||
SPATIAL_INDEX_ENABLED=True
|
||||
SURFACE_LAS_CHUNK_SIZE=500000
|
||||
SURFACE_DEFAULT_RGB_VALUE=128
|
||||
SURFACE_GRID_CELL_SIZE_M=2.0
|
||||
SURFACE_GRID_HEIGHT_THRESHOLD_M=1.5
|
||||
|
||||
# 인증
|
||||
JWT_SECRET_KEY=your-secret-key-change-in-production
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/* B03 다중 파일 업로드 API 클라이언트 */
|
||||
|
||||
import { API_BASE_URL, API_TIMEOUT_MS, AUTH_TOKEN_KEY } from "@config/config_frontend";
|
||||
|
||||
export interface UploadedFileResult {
|
||||
input_file_id: number;
|
||||
original_filename: string;
|
||||
file_type: string;
|
||||
relative_path: string;
|
||||
size_bytes: number;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface FileUploadResponse {
|
||||
status: string;
|
||||
project_id: string;
|
||||
files: UploadedFileResult[];
|
||||
}
|
||||
|
||||
export async function uploadProjectFiles(
|
||||
projectId: string,
|
||||
files: readonly File[],
|
||||
): Promise<FileUploadResponse> {
|
||||
const formData = new FormData();
|
||||
for (const file of files) formData.append("files", file, file.name);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
const token = localStorage.getItem(AUTH_TOKEN_KEY);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/files`, {
|
||||
method: "POST",
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const payload = (await response.json()) as Partial<FileUploadResponse> & {
|
||||
message?: string;
|
||||
};
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
}
|
||||
return payload as FileUploadResponse;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"""B03 파일 입력 저장 엔진."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import UploadFile
|
||||
|
||||
from B03_FileInput.B03_FileInput_Schema import FileUploadDescriptor
|
||||
from common_util.common_util_storage import get_project_stage_path
|
||||
from config.config_system import UPLOAD_CHUNK_SIZE_BYTES, UPLOAD_MAX_MB
|
||||
|
||||
|
||||
def resolve_upload_destination(
|
||||
project_root: str | Path,
|
||||
descriptor: FileUploadDescriptor,
|
||||
) -> Path:
|
||||
"""검증된 파일의 B03 입력 저장 경로를 생성해 반환한다."""
|
||||
stage_root = Path(get_project_stage_path(str(project_root), "B03_FileInput")).resolve()
|
||||
file_type = Path(descriptor.original_filename).suffix.lower().lstrip(".")
|
||||
destination = (stage_root / "input" / file_type / descriptor.original_filename).resolve()
|
||||
|
||||
if os.path.commonpath((stage_root, destination)) != str(stage_root):
|
||||
raise ValueError("업로드 저장 경로가 B03 단계 폴더를 벗어났습니다.")
|
||||
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
return destination
|
||||
|
||||
|
||||
async def save_upload_stream(upload: UploadFile, destination: Path) -> int:
|
||||
"""업로드 스트림을 크기 제한 내에서 임시 파일에 기록한 뒤 교체한다."""
|
||||
maximum_bytes = UPLOAD_MAX_MB * 1024 * 1024
|
||||
written_bytes = 0
|
||||
temporary_path: Path | None = None
|
||||
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="wb",
|
||||
dir=destination.parent,
|
||||
prefix=f".{destination.name}.",
|
||||
suffix=".upload",
|
||||
delete=False,
|
||||
) as temporary:
|
||||
temporary_path = Path(temporary.name)
|
||||
while chunk := await upload.read(UPLOAD_CHUNK_SIZE_BYTES):
|
||||
written_bytes += len(chunk)
|
||||
if written_bytes > maximum_bytes:
|
||||
raise ValueError(f"파일 크기는 {UPLOAD_MAX_MB}MB를 초과할 수 없습니다.")
|
||||
temporary.write(chunk)
|
||||
temporary.flush()
|
||||
os.fsync(temporary.fileno())
|
||||
|
||||
if written_bytes == 0:
|
||||
raise ValueError("빈 파일은 업로드할 수 없습니다.")
|
||||
os.replace(temporary_path, destination)
|
||||
temporary_path = None
|
||||
return written_bytes
|
||||
finally:
|
||||
if temporary_path is not None:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
@@ -0,0 +1,162 @@
|
||||
"""B03 원본 입력 파일 메타데이터 분석."""
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import laspy
|
||||
import numpy as np
|
||||
import rasterio
|
||||
from pyproj import CRS
|
||||
|
||||
|
||||
def analyze_las_metadata(path: str | Path) -> dict[str, Any]:
|
||||
"""LAS/LAZ 헤더와 분류 통계를 메모리에 전체 적재하지 않고 분석한다."""
|
||||
source = Path(path)
|
||||
with laspy.open(source) as las_file:
|
||||
header = las_file.header
|
||||
point_format = header.point_format
|
||||
dimension_names = list(point_format.dimension_names)
|
||||
point_count = int(header.point_count)
|
||||
crs = header.parse_crs()
|
||||
metadata: dict[str, Any] = {
|
||||
"file": source.name,
|
||||
"version": f"{header.version.major}.{header.version.minor}",
|
||||
"point_format": {
|
||||
"id": point_format.id,
|
||||
"dimensions": dimension_names,
|
||||
},
|
||||
"point_count": point_count,
|
||||
"bounds": {
|
||||
"x": [float(header.mins[0]), float(header.maxs[0])],
|
||||
"y": [float(header.mins[1]), float(header.maxs[1])],
|
||||
"z": [float(header.mins[2]), float(header.maxs[2])],
|
||||
},
|
||||
"scale": [float(value) for value in header.scales],
|
||||
"offset": [float(value) for value in header.offsets],
|
||||
"has_crs": crs is not None,
|
||||
"crs": crs.to_string() if crs else None,
|
||||
"epsg": crs.to_epsg() if crs else None,
|
||||
"has_classification": "classification" in dimension_names,
|
||||
"has_rgb": all(name in dimension_names for name in ("red", "green", "blue")),
|
||||
"has_intensity": "intensity" in dimension_names,
|
||||
"has_return_number": "return_number" in dimension_names,
|
||||
}
|
||||
|
||||
if metadata["has_classification"] and point_count > 0:
|
||||
classification_counts: dict[int, int] = {}
|
||||
for chunk in las_file.chunk_iterator(500_000):
|
||||
values, counts = np.unique(
|
||||
np.asarray(chunk.classification, dtype=np.uint8),
|
||||
return_counts=True,
|
||||
)
|
||||
for value, count in zip(values.tolist(), counts.tolist(), strict=True):
|
||||
classification_counts[value] = classification_counts.get(value, 0) + count
|
||||
metadata["classification_summary"] = {
|
||||
str(key): value for key, value in sorted(classification_counts.items())
|
||||
}
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
def analyze_prj_metadata(path: str | Path) -> dict[str, Any]:
|
||||
"""PRJ WKT에서 좌표계 식별자와 명칭을 추출한다."""
|
||||
source = Path(path)
|
||||
text = source.read_text(encoding="utf-8", errors="replace").strip()
|
||||
metadata: dict[str, Any] = {
|
||||
"file": source.name,
|
||||
"text_preview": text[:500],
|
||||
"epsg": None,
|
||||
"name": None,
|
||||
"authority": None,
|
||||
"is_valid": False,
|
||||
}
|
||||
if not text:
|
||||
metadata["error"] = "PRJ 파일이 비어 있습니다."
|
||||
return metadata
|
||||
|
||||
try:
|
||||
crs = CRS.from_wkt(text)
|
||||
except Exception as exc:
|
||||
metadata["error"] = str(exc)
|
||||
return metadata
|
||||
|
||||
metadata.update(
|
||||
{
|
||||
"epsg": crs.to_epsg(),
|
||||
"name": crs.name,
|
||||
"authority": crs.to_authority(),
|
||||
"is_valid": True,
|
||||
}
|
||||
)
|
||||
return metadata
|
||||
|
||||
|
||||
def analyze_tfw_metadata(path: str | Path) -> dict[str, Any]:
|
||||
"""TFW의 affine 변환 계수와 유효성을 분석한다."""
|
||||
source = Path(path)
|
||||
values = [
|
||||
float(line.strip())
|
||||
for line in source.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
if any(not math.isfinite(value) for value in values):
|
||||
raise ValueError("TFW 변환 계수는 유한한 숫자여야 합니다.")
|
||||
|
||||
return {
|
||||
"file": source.name,
|
||||
"values": values,
|
||||
"pixel_size_x": values[0] if len(values) > 0 else None,
|
||||
"rotation_y": values[1] if len(values) > 1 else None,
|
||||
"rotation_x": values[2] if len(values) > 2 else None,
|
||||
"pixel_size_y": values[3] if len(values) > 3 else None,
|
||||
"origin_x": values[4] if len(values) > 4 else None,
|
||||
"origin_y": values[5] if len(values) > 5 else None,
|
||||
"is_valid": len(values) == 6,
|
||||
}
|
||||
|
||||
|
||||
def analyze_tif_metadata(path: str | Path) -> dict[str, Any]:
|
||||
"""TIF/GeoTIFF 데이터셋의 공간 및 밴드 메타데이터를 분석한다."""
|
||||
source = Path(path)
|
||||
with rasterio.open(source) as dataset:
|
||||
crs = dataset.crs
|
||||
bounds = dataset.bounds
|
||||
return {
|
||||
"file": source.name,
|
||||
"width": int(dataset.width),
|
||||
"height": int(dataset.height),
|
||||
"count": int(dataset.count),
|
||||
"dtypes": list(dataset.dtypes),
|
||||
"nodata": float(dataset.nodata) if dataset.nodata is not None else None,
|
||||
"crs": crs.to_string() if crs else None,
|
||||
"epsg": crs.to_epsg() if crs else None,
|
||||
"bounds": {
|
||||
"left": float(bounds.left),
|
||||
"bottom": float(bounds.bottom),
|
||||
"right": float(bounds.right),
|
||||
"top": float(bounds.top),
|
||||
},
|
||||
"transform": [float(value) for value in list(dataset.transform)[:6]],
|
||||
"resolution": [float(value) for value in dataset.res],
|
||||
"likely_type": "dem" if dataset.count == 1 else "image",
|
||||
}
|
||||
|
||||
|
||||
def analyze_input_metadata(path: str | Path) -> dict[str, Any]:
|
||||
"""입력 파일 확장자에 맞는 B03 메타데이터 분석 함수를 호출한다."""
|
||||
source = Path(path)
|
||||
extension = source.suffix.lower()
|
||||
if extension in {".las", ".laz"}:
|
||||
return analyze_las_metadata(source)
|
||||
if extension == ".prj":
|
||||
return analyze_prj_metadata(source)
|
||||
if extension == ".tfw":
|
||||
return analyze_tfw_metadata(source)
|
||||
if extension in {".tif", ".tiff"}:
|
||||
return analyze_tif_metadata(source)
|
||||
return {
|
||||
"file": source.name,
|
||||
"extension": extension.lstrip("."),
|
||||
"size_bytes": source.stat().st_size,
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"""B03 input_files 테이블의 aiomysql Raw SQL 접근."""
|
||||
|
||||
import json
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import aiomysql
|
||||
|
||||
|
||||
async def create_input_file(
|
||||
connection: aiomysql.Connection,
|
||||
*,
|
||||
project_id: UUID,
|
||||
file_type: str,
|
||||
original_filename: str,
|
||||
relative_path: str,
|
||||
file_size_bytes: int,
|
||||
upload_by: int | None,
|
||||
crs_epsg: int | None,
|
||||
metadata: dict[str, Any],
|
||||
) -> int:
|
||||
"""업로드 원본 파일 메타데이터를 저장하고 생성된 ID를 반환한다."""
|
||||
normalized_path = PurePosixPath(relative_path)
|
||||
if normalized_path.is_absolute() or ".." in normalized_path.parts:
|
||||
raise ValueError("DB에는 프로젝트 루트 기준 상대 경로만 저장할 수 있습니다.")
|
||||
if normalized_path.parts[:2] != ("B03_FileInput", "input"):
|
||||
raise ValueError("입력 파일 경로는 B03_FileInput/input 아래여야 합니다.")
|
||||
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
INSERT INTO input_files (
|
||||
project_id,
|
||||
file_type,
|
||||
original_filename,
|
||||
raw_file_path,
|
||||
file_size_mb,
|
||||
upload_by,
|
||||
crs_epsg,
|
||||
metadata,
|
||||
status
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'UPLOADED')
|
||||
""",
|
||||
(
|
||||
str(project_id),
|
||||
file_type,
|
||||
original_filename,
|
||||
normalized_path.as_posix(),
|
||||
file_size_bytes / (1024 * 1024),
|
||||
upload_by,
|
||||
crs_epsg,
|
||||
json.dumps(metadata, ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
input_file_id = cursor.lastrowid
|
||||
|
||||
if not input_file_id:
|
||||
raise RuntimeError("input_files 레코드 생성 결과에 ID가 없습니다.")
|
||||
return int(input_file_id)
|
||||
|
||||
|
||||
async def get_project_storage_relative_path(
|
||||
connection: aiomysql.Connection,
|
||||
project_id: UUID,
|
||||
) -> str:
|
||||
"""프로젝트의 검증된 저장소 상대 경로를 조회한다."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT storage_path
|
||||
FROM projects
|
||||
WHERE id = %s AND deleted_at IS NULL
|
||||
""",
|
||||
(str(project_id),),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if not row or not row[0]:
|
||||
raise LookupError("프로젝트 또는 프로젝트 저장 경로를 찾을 수 없습니다.")
|
||||
|
||||
normalized_path = PurePosixPath(str(row[0]).replace("\\", "/"))
|
||||
if normalized_path.is_absolute() or ".." in normalized_path.parts:
|
||||
raise ValueError("프로젝트 저장 경로는 안전한 상대 경로여야 합니다.")
|
||||
return normalized_path.as_posix()
|
||||
@@ -0,0 +1,144 @@
|
||||
"""B03 파일 입력 FastAPI 라우터."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, File, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B03_FileInput.B03_FileInput_Engine import (
|
||||
resolve_upload_destination,
|
||||
save_upload_stream,
|
||||
)
|
||||
from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata
|
||||
from B03_FileInput.B03_FileInput_Repository import (
|
||||
create_input_file,
|
||||
get_project_storage_relative_path,
|
||||
)
|
||||
from B03_FileInput.B03_FileInput_Schema import (
|
||||
FileUploadDescriptor,
|
||||
FileUploadResponse,
|
||||
UploadedFileResult,
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_workflow import load_project_workflow
|
||||
from config.config_db import get_db_pool
|
||||
from config.config_system import UPLOAD_MAX_FILES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B03 File Input"])
|
||||
|
||||
|
||||
@router.post("/{project_id}/files", response_model=FileUploadResponse)
|
||||
async def upload_project_files(
|
||||
project_id: UUID,
|
||||
files: list[UploadFile] = File(...),
|
||||
) -> FileUploadResponse | JSONResponse:
|
||||
"""프로젝트 입력 파일을 저장·분석하고 DB 메타데이터를 기록한다."""
|
||||
if not files or len(files) > UPLOAD_MAX_FILES:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": f"파일은 1~{UPLOAD_MAX_FILES}개까지 가능합니다.",
|
||||
},
|
||||
)
|
||||
|
||||
filenames = [upload.filename or "" for upload in files]
|
||||
if len({filename.casefold() for filename in filenames}) != len(filenames):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"status": "error", "message": "동일한 파일명을 중복 업로드할 수 없습니다."},
|
||||
)
|
||||
las_count = sum(Path(filename).suffix.lower() in {".las", ".laz"} for filename in filenames)
|
||||
if las_count != 1:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": "LAS 또는 LAZ 파일을 정확히 1개 포함해야 합니다.",
|
||||
},
|
||||
)
|
||||
|
||||
pool = get_db_pool()
|
||||
saved_paths: list[Path] = []
|
||||
try:
|
||||
results: list[UploadedFileResult] = []
|
||||
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
|
||||
await connection.begin()
|
||||
try:
|
||||
for upload in files:
|
||||
preliminary = FileUploadDescriptor(
|
||||
original_filename=upload.filename or "",
|
||||
size_bytes=max(upload.size or 0, 1),
|
||||
)
|
||||
destination = resolve_upload_destination(project_root, preliminary)
|
||||
written_bytes = await save_upload_stream(upload, destination)
|
||||
saved_paths.append(destination)
|
||||
descriptor = FileUploadDescriptor(
|
||||
original_filename=preliminary.original_filename,
|
||||
size_bytes=written_bytes,
|
||||
)
|
||||
metadata = await asyncio.to_thread(analyze_input_metadata, destination)
|
||||
relative_path = destination.relative_to(project_root).as_posix()
|
||||
file_type = destination.suffix.lower().lstrip(".")
|
||||
crs_epsg = metadata.get("epsg")
|
||||
input_file_id = await create_input_file(
|
||||
connection,
|
||||
project_id=project_id,
|
||||
file_type=file_type,
|
||||
original_filename=descriptor.original_filename,
|
||||
relative_path=relative_path,
|
||||
file_size_bytes=written_bytes,
|
||||
upload_by=None,
|
||||
crs_epsg=int(crs_epsg) if crs_epsg is not None else None,
|
||||
metadata=metadata,
|
||||
)
|
||||
results.append(
|
||||
UploadedFileResult(
|
||||
input_file_id=input_file_id,
|
||||
original_filename=descriptor.original_filename,
|
||||
file_type=file_type,
|
||||
relative_path=relative_path,
|
||||
size_bytes=written_bytes,
|
||||
metadata=metadata,
|
||||
)
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
|
||||
stage_root = project_root / "B03_FileInput"
|
||||
atomic_write_json(
|
||||
stage_root / "metadata.json",
|
||||
{"project_id": str(project_id), "files": [result.model_dump() for result in results]},
|
||||
)
|
||||
workflow_path = project_root / "workflow.json"
|
||||
if not workflow_path.exists():
|
||||
atomic_write_json(workflow_path, load_project_workflow(project_root))
|
||||
return FileUploadResponse(project_id=str(project_id), files=results)
|
||||
except LookupError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except (OSError, ValueError) as exc:
|
||||
for saved_path in saved_paths:
|
||||
saved_path.unlink(missing_ok=True)
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
for saved_path in saved_paths:
|
||||
saved_path.unlink(missing_ok=True)
|
||||
logger.exception("B03 파일 업로드 처리 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "파일 업로드 처리 중 오류가 발생했습니다."},
|
||||
)
|
||||
finally:
|
||||
for upload in files:
|
||||
await upload.close()
|
||||
@@ -0,0 +1,52 @@
|
||||
"""B03 파일 입력 요청·응답 검증 모델."""
|
||||
|
||||
from pathlib import PurePath
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from config.config_system import UPLOAD_ALLOWED_EXT, UPLOAD_MAX_MB
|
||||
|
||||
|
||||
class FileUploadDescriptor(BaseModel):
|
||||
"""업로드 전에 검증할 단일 파일의 이름과 크기."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||
|
||||
original_filename: str = Field(min_length=1, max_length=255)
|
||||
size_bytes: int = Field(gt=0)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_file(self) -> "FileUploadDescriptor":
|
||||
filename = self.original_filename
|
||||
if PurePath(filename).name != filename or "/" in filename or "\\" in filename:
|
||||
raise ValueError("파일명에는 경로가 포함될 수 없습니다.")
|
||||
|
||||
extension = PurePath(filename).suffix.lower()
|
||||
if extension not in UPLOAD_ALLOWED_EXT:
|
||||
allowed = ", ".join(UPLOAD_ALLOWED_EXT)
|
||||
raise ValueError(f"허용되지 않은 파일 확장자입니다. 허용 형식: {allowed}")
|
||||
|
||||
maximum_bytes = UPLOAD_MAX_MB * 1024 * 1024
|
||||
if self.size_bytes > maximum_bytes:
|
||||
raise ValueError(f"파일 크기는 {UPLOAD_MAX_MB}MB를 초과할 수 없습니다.")
|
||||
return self
|
||||
|
||||
|
||||
class UploadedFileResult(BaseModel):
|
||||
"""저장 완료된 단일 입력 파일 정보."""
|
||||
|
||||
input_file_id: int
|
||||
original_filename: str
|
||||
file_type: str
|
||||
relative_path: str
|
||||
size_bytes: int
|
||||
metadata: dict[str, Any]
|
||||
|
||||
|
||||
class FileUploadResponse(BaseModel):
|
||||
"""B03 다중 파일 업로드 성공 응답."""
|
||||
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
files: list[UploadedFileResult]
|
||||
@@ -1,29 +1,188 @@
|
||||
/* =============================================================================
|
||||
* B03_FileInput_UI_Page.ts
|
||||
* 로그인 후 03: 파일 입력 (지형·포인트클라우드·도면 업로드)
|
||||
*
|
||||
* ⚠️ 본문(업로드 드롭존/파일 목록)은 준비 중 — 헤더/안내만 구현.
|
||||
* 실제 업로드 UI는 0_old 참고하여 추후 구체화.
|
||||
*
|
||||
* 제약 준수 (frontend.md): 문구는 locale 참조(§3), 공통 컴포넌트 사용(§2).
|
||||
* ========================================================================== */
|
||||
/* B03 파일 입력 페이지 */
|
||||
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { renderPendingContent } from "../A00_Common/b_page_scaffold";
|
||||
import {
|
||||
CURRENT_PROJECT_ID_KEY,
|
||||
UPLOAD_ALLOWED_EXT,
|
||||
UPLOAD_MAX_FILES,
|
||||
UPLOAD_MAX_MB,
|
||||
} from "@config/config_frontend";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createButton,
|
||||
createCard,
|
||||
hideLoadingOverlay,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { uploadProjectFiles, type UploadedFileResult } from "./B03_FileInput_Api_Fetch";
|
||||
import "./B03_FileInput_UI_Style.css";
|
||||
|
||||
/** locale 헬퍼 */
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* 페이지 진입점
|
||||
* -------------------------------------------------------------------------- */
|
||||
function validateFiles(files: readonly File[]): string | null {
|
||||
if (files.length === 0) return L("B03_File_Error_Required");
|
||||
if (files.length > UPLOAD_MAX_FILES) return L("B03_File_Error_Count");
|
||||
|
||||
const maximumBytes = UPLOAD_MAX_MB * 1024 * 1024;
|
||||
const normalizedNames = new Set<string>();
|
||||
let lasCount = 0;
|
||||
for (const file of files) {
|
||||
const extensionIndex = file.name.lastIndexOf(".");
|
||||
const extension = extensionIndex >= 0 ? file.name.slice(extensionIndex).toLowerCase() : "";
|
||||
if (!UPLOAD_ALLOWED_EXT.includes(extension as (typeof UPLOAD_ALLOWED_EXT)[number])) {
|
||||
return `${L("B03_File_Error_Extension")} ${file.name}`;
|
||||
}
|
||||
if (file.size === 0 || file.size > maximumBytes) {
|
||||
return `${L("B03_File_Error_Size")} ${file.name}`;
|
||||
}
|
||||
const normalizedName = file.name.toLocaleLowerCase();
|
||||
if (normalizedNames.has(normalizedName)) return `${L("B03_File_Error_Extension")} ${file.name}`;
|
||||
normalizedNames.add(normalizedName);
|
||||
if (extension === ".las" || extension === ".laz") lasCount += 1;
|
||||
}
|
||||
return lasCount === 1 ? null : L("B03_File_Error_Las");
|
||||
}
|
||||
|
||||
export function renderB03FileInput(root: HTMLElement): void {
|
||||
renderPendingContent(root, {
|
||||
pageClass: "b03-file",
|
||||
title: L("B03_File_Title"),
|
||||
subtitle: L("B03_File_Subtitle"),
|
||||
let selectedFiles: File[] = [];
|
||||
const page = document.createElement("div");
|
||||
page.className = "b03-file";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "b03-file__header";
|
||||
const title = document.createElement("h1");
|
||||
title.className = "b03-file__title";
|
||||
title.textContent = L("B03_File_Title");
|
||||
const subtitle = document.createElement("p");
|
||||
subtitle.className = "b03-file__subtitle";
|
||||
subtitle.textContent = L("B03_File_Subtitle");
|
||||
header.append(title, subtitle);
|
||||
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.multiple = true;
|
||||
fileInput.accept = UPLOAD_ALLOWED_EXT.join(",");
|
||||
fileInput.className = "b03-file__native-input";
|
||||
|
||||
const dropzone = document.createElement("div");
|
||||
dropzone.className = "b03-file__dropzone";
|
||||
dropzone.tabIndex = 0;
|
||||
const dropzoneLabel = document.createElement("strong");
|
||||
dropzoneLabel.textContent = L("B03_File_Select_Label");
|
||||
const dropzoneHint = document.createElement("span");
|
||||
dropzoneHint.textContent = L("B03_File_Select_Hint");
|
||||
dropzone.append(dropzoneLabel, dropzoneHint, fileInput);
|
||||
|
||||
const errorSlot = document.createElement("p");
|
||||
errorSlot.className = "b03-file__error";
|
||||
errorSlot.setAttribute("role", "alert");
|
||||
const selectedTitle = document.createElement("h2");
|
||||
selectedTitle.className = "b03-file__section-title";
|
||||
selectedTitle.textContent = L("B03_File_Selected_Title");
|
||||
const selectedList = document.createElement("ul");
|
||||
selectedList.className = "b03-file__list";
|
||||
const resultList = document.createElement("ul");
|
||||
resultList.className = "b03-file__results";
|
||||
|
||||
function renderSelectedFiles(): void {
|
||||
selectedList.replaceChildren();
|
||||
if (selectedFiles.length === 0) {
|
||||
const empty = document.createElement("li");
|
||||
empty.className = "b03-file__empty";
|
||||
empty.textContent = L("B03_File_Selected_Empty");
|
||||
selectedList.append(empty);
|
||||
return;
|
||||
}
|
||||
for (const file of selectedFiles) {
|
||||
const item = document.createElement("li");
|
||||
const name = document.createElement("span");
|
||||
name.textContent = file.name;
|
||||
const size = document.createElement("span");
|
||||
size.textContent = `${(file.size / (1024 * 1024)).toFixed(2)} MB`;
|
||||
item.append(name, size);
|
||||
selectedList.append(item);
|
||||
}
|
||||
}
|
||||
|
||||
function setSelectedFiles(files: readonly File[]): void {
|
||||
selectedFiles = Array.from(files);
|
||||
errorSlot.textContent = validateFiles(selectedFiles) ?? "";
|
||||
renderSelectedFiles();
|
||||
}
|
||||
|
||||
function renderUploadResults(results: readonly UploadedFileResult[]): void {
|
||||
resultList.replaceChildren();
|
||||
for (const result of results) {
|
||||
const item = document.createElement("li");
|
||||
const filename = document.createElement("strong");
|
||||
filename.textContent = result.original_filename;
|
||||
const path = document.createElement("span");
|
||||
path.textContent = `${L("B03_File_Result_Path")}: ${result.relative_path}`;
|
||||
item.append(filename, path);
|
||||
resultList.append(item);
|
||||
}
|
||||
}
|
||||
|
||||
function onB03_File_Select_Change(): void {
|
||||
setSelectedFiles(fileInput.files ? Array.from(fileInput.files) : []);
|
||||
}
|
||||
|
||||
function onB03_File_Drop(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
dropzone.classList.remove("is-dragging");
|
||||
setSelectedFiles(event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : []);
|
||||
}
|
||||
|
||||
async function onB03_File_Upload_Click(): Promise<void> {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
const validationError = validateFiles(selectedFiles);
|
||||
if (!projectId) {
|
||||
errorSlot.textContent = L("B03_File_Error_Project");
|
||||
return;
|
||||
}
|
||||
if (validationError) {
|
||||
errorSlot.textContent = validationError;
|
||||
return;
|
||||
}
|
||||
|
||||
errorSlot.textContent = "";
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const response = await uploadProjectFiles(projectId, selectedFiles);
|
||||
renderUploadResults(response.files);
|
||||
showToast(L("B03_File_Upload_Success"), "success");
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : L("B03_File_Upload_Failed");
|
||||
errorSlot.textContent = `${L("B03_File_Upload_Failed")} ${detail}`;
|
||||
showToast(L("B03_File_Upload_Failed"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
fileInput.addEventListener("change", onB03_File_Select_Change);
|
||||
dropzone.addEventListener("click", () => fileInput.click());
|
||||
dropzone.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") fileInput.click();
|
||||
});
|
||||
dropzone.addEventListener("dragover", (event) => {
|
||||
event.preventDefault();
|
||||
dropzone.classList.add("is-dragging");
|
||||
});
|
||||
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("is-dragging"));
|
||||
dropzone.addEventListener("drop", onB03_File_Drop);
|
||||
|
||||
const uploadButton = createButton({
|
||||
label: L("B03_File_Upload_Button"),
|
||||
variant: "filled",
|
||||
onClick: () => void onB03_File_Upload_Click(),
|
||||
});
|
||||
const body = document.createElement("div");
|
||||
body.className = "b03-file__body";
|
||||
body.append(dropzone, errorSlot, selectedTitle, selectedList, uploadButton, resultList);
|
||||
page.append(header, createCard({ body: [body], raised: true }));
|
||||
root.replaceChildren(page);
|
||||
renderSelectedFiles();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,118 @@
|
||||
/* =============================================================================
|
||||
* B03_FileInput_UI_Style.css
|
||||
* 파일 입력 페이지 전용 스타일 (theme.css 변수만 사용)
|
||||
* ⚠️ 본문(업로드 드롭존) 준비 중 — 현재는 공통 스캐폴드 스타일에 위임.
|
||||
* ========================================================================== */
|
||||
.b03-file {
|
||||
max-width: var(--page-max-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-40) var(--spacing-24);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-24);
|
||||
}
|
||||
|
||||
/* 본문 구현 시 .b03-file 하위에 업로드 드롭존/파일 목록 스타일 추가 예정 */
|
||||
.b03-file__header,
|
||||
.b03-file__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
.b03-file__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-heading);
|
||||
line-height: 1;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.b03-file__subtitle,
|
||||
.b03-file__dropzone span,
|
||||
.b03-file__empty {
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.b03-file__native-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b03-file__dropzone {
|
||||
min-height: 180px;
|
||||
padding: var(--spacing-24);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-8);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.b03-file__dropzone:focus-visible,
|
||||
.b03-file__dropzone.is-dragging {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: 2px;
|
||||
background: var(--color-mist-violet);
|
||||
}
|
||||
|
||||
.b03-file__dropzone strong,
|
||||
.b03-file__section-title,
|
||||
.b03-file__results strong {
|
||||
color: var(--color-text);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.b03-file__section-title {
|
||||
font-size: var(--text-subheading);
|
||||
}
|
||||
|
||||
.b03-file__error {
|
||||
min-height: var(--spacing-16);
|
||||
color: var(--color-error);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.b03-file__list,
|
||||
.b03-file__results {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.b03-file__list li,
|
||||
.b03-file__results li {
|
||||
padding: var(--spacing-16);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-16);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.b03-file__list li:nth-child(even),
|
||||
.b03-file__results li:nth-child(even) {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.b03-file__list li:last-child,
|
||||
.b03-file__results li:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.b03-file__results:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b03-file__results li {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.b03-file__list li {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/* =============================================================================
|
||||
* B04_wf1_Surface_Api_Fetch.ts
|
||||
* 1차 워크플로우(지표면 분석) API 클라이언트
|
||||
*
|
||||
* 백엔드 계약 (B04_wf1_Surface_Router.py):
|
||||
* POST /api/projects/{project_id}/surface/analyze → 분석 실행 + DB 기록
|
||||
* GET /api/projects/{project_id}/surface/models → 모델 목록 조회
|
||||
*
|
||||
* 규칙:
|
||||
* - 모든 제어 상수는 config_frontend에서 참조 (하드코딩 금지).
|
||||
* - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL, API_TIMEOUT_MS, AUTH_TOKEN_KEY } from "@config/config_frontend";
|
||||
|
||||
/** 지표면 분석 실행 요청 (SurfaceAnalyzeRequest) */
|
||||
export interface SurfaceAnalyzeRequest {
|
||||
input_file_id: number;
|
||||
source_filters?: string[];
|
||||
methods?: string[];
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
/** 지표면 분석 실행 결과 (SurfaceAnalyzeResponse) */
|
||||
export interface SurfaceAnalyzeResponse {
|
||||
status: string;
|
||||
project_id: string;
|
||||
ground_summary: Record<string, unknown>;
|
||||
manifest_status: string;
|
||||
surface_model_ids: number[];
|
||||
}
|
||||
|
||||
/** 저장된 지표면 모델 요약 (SurfaceModelSummary) */
|
||||
export interface SurfaceModelSummary {
|
||||
id: number;
|
||||
model_type: string;
|
||||
status: string;
|
||||
resolution_m: number | null;
|
||||
model_file_path: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
/** 지표면 모델 목록 응답 (SurfaceModelListResponse) */
|
||||
export interface SurfaceModelListResponse {
|
||||
status: string;
|
||||
project_id: string;
|
||||
models: SurfaceModelSummary[];
|
||||
}
|
||||
|
||||
/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */
|
||||
async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
const token = localStorage.getItem(AUTH_TOKEN_KEY);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
const payload = (await response.json()) as T & { message?: string };
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
}
|
||||
return payload;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/** 지표면 분석을 실행한다 (LAS 구조화 → 지면 필터 → 지표면 모델 생성). */
|
||||
export async function analyzeSurface(
|
||||
projectId: string,
|
||||
request: SurfaceAnalyzeRequest,
|
||||
): Promise<SurfaceAnalyzeResponse> {
|
||||
return requestJson<SurfaceAnalyzeResponse>(`/projects/${projectId}/surface/analyze`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
/** 프로젝트의 지표면 모델 목록을 조회한다. */
|
||||
export async function listSurfaceModels(projectId: string): Promise<SurfaceModelListResponse> {
|
||||
return requestJson<SurfaceModelListResponse>(`/projects/${projectId}/surface/models`, {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"""B04 지표면 분석 엔진 오케스트레이터.
|
||||
|
||||
원본 LAS를 구조화하고 지면 필터를 실행한 뒤 지표면 5종 모델을 빌드하는
|
||||
동기 계산 파이프라인. 라우터에서 asyncio.to_thread로 호출한다.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Ground import build_ground_masks, summarize_masks
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Pipeline import build_all_terrain_models
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Structurize import structurize_las
|
||||
from config.config_system import build_surface_model_config
|
||||
|
||||
|
||||
def _relative_to_project(project_root: Path, path: Path) -> str:
|
||||
"""프로젝트 루트 기준 posix 상대 경로 문자열."""
|
||||
return path.relative_to(project_root).as_posix()
|
||||
|
||||
|
||||
def run_surface_analysis(
|
||||
project_root: Path,
|
||||
las_path: Path,
|
||||
*,
|
||||
source_filters: list[str],
|
||||
methods: list[str],
|
||||
force: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""구조화→필터→모델 빌드를 수행하고 산출 메타데이터를 반환한다.
|
||||
|
||||
반환 dict:
|
||||
- processed: {processed_file_path, converted_file_path, point_count, bounds, statistics}
|
||||
- ground_summary: 필터별 지면 포인트 요약
|
||||
- manifest: 지표면 모델 파이프라인 manifest
|
||||
- models: [{model_type, model_file_path, resolution_m, generation_params, layers}]
|
||||
"""
|
||||
stage_root = project_root / "B04_wf1_Surface"
|
||||
processed_dir = stage_root / "processed"
|
||||
models_dir = stage_root / "models"
|
||||
processed_dir.mkdir(parents=True, exist_ok=True)
|
||||
models_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 1. LAS 구조화 (structured.npz)
|
||||
structured_path = structurize_las(las_path, processed_dir)
|
||||
with np.load(structured_path) as structured:
|
||||
xyz = structured["xyz"]
|
||||
bounds = structured["bounds"]
|
||||
total_points = int(len(xyz))
|
||||
stats = {
|
||||
"min_z": float(bounds[2, 0]),
|
||||
"max_z": float(bounds[2, 1]),
|
||||
"mean_z": float(np.mean(xyz[:, 2])) if total_points else None,
|
||||
}
|
||||
bounds_dict = {
|
||||
"x_min": float(bounds[0, 0]),
|
||||
"x_max": float(bounds[0, 1]),
|
||||
"y_min": float(bounds[1, 0]),
|
||||
"y_max": float(bounds[1, 1]),
|
||||
}
|
||||
data = {"xyz": xyz, "bounds": bounds}
|
||||
|
||||
# 2. 지면 필터 실행
|
||||
masks = build_ground_masks(data, source_filters)
|
||||
ground_summary = summarize_masks(data, masks)
|
||||
|
||||
# 3. 지표면 5종 모델 빌드
|
||||
config = build_surface_model_config()
|
||||
config["source_filters"] = list(source_filters)
|
||||
config["precompute"] = list(methods)
|
||||
manifest = build_all_terrain_models(data, masks, models_dir, config, force=force)
|
||||
|
||||
processed = {
|
||||
"processed_file_path": _relative_to_project(project_root, structured_path),
|
||||
"converted_file_path": None,
|
||||
"point_count": total_points,
|
||||
"bounds": bounds_dict,
|
||||
"statistics": stats,
|
||||
}
|
||||
|
||||
# manifest에서 저장된 모델별 정보 추출 (필터별 대표 모델)
|
||||
models: list[dict[str, Any]] = []
|
||||
for filter_key, filter_entry in manifest.get("source_filters", {}).items():
|
||||
for method, meta in filter_entry.get("methods", {}).items():
|
||||
if meta.get("status") != "completed":
|
||||
continue
|
||||
model_file = meta.get("model_file")
|
||||
model_path = (models_dir / model_file) if model_file else None
|
||||
layers: list[dict[str, Any]] = []
|
||||
if meta.get("preview_file"):
|
||||
layers.append(
|
||||
{
|
||||
"layer_name": f"{method}_{filter_key}_preview",
|
||||
"geometry_type": "MESH" if method != "meshfree" else "POINTCLOUD",
|
||||
"file_path": _relative_to_project(
|
||||
project_root, models_dir / meta["preview_file"]
|
||||
),
|
||||
"file_format": "glb" if method != "meshfree" else "ply",
|
||||
}
|
||||
)
|
||||
models.append(
|
||||
{
|
||||
"model_type": method,
|
||||
"source_filter": filter_key,
|
||||
"representation": meta.get("representation"),
|
||||
"model_file_path": _relative_to_project(project_root, model_path)
|
||||
if model_path
|
||||
else None,
|
||||
"resolution_m": meta.get("grid_resolution_meters"),
|
||||
"generation_params": {
|
||||
"source_filter": filter_key,
|
||||
"representation": meta.get("representation"),
|
||||
"footprint_area_m2": meta.get("footprint_area_m2"),
|
||||
},
|
||||
"layers": layers,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"processed": processed,
|
||||
"ground_summary": ground_summary,
|
||||
"manifest": manifest,
|
||||
"models": models,
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
"""B04 등고선 추출 엔진.
|
||||
|
||||
5종 표현(regular_grid/triangular_mesh/bspline_surface/local_rbf_height_field/
|
||||
meshfree_surfels)의 npz 모델에서 표고 격자를 환원하고, marching squares로
|
||||
지정 간격 등고선 라인을 추출한다. DTM valid_mask를 footprint로 사용해
|
||||
경계 누출을 차단한다.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from scipy.interpolate import RBFInterpolator, RectBivariateSpline
|
||||
from skimage import measure
|
||||
|
||||
# 등고선 캐시 형식/추출 규칙이 바뀔 때 증가시킨다.
|
||||
CONTOUR_EXTRACTOR_VERSION = 3
|
||||
|
||||
|
||||
def extract_contours_from_grid(
|
||||
x_coords: np.ndarray,
|
||||
y_coords: np.ndarray,
|
||||
z_grid: np.ndarray,
|
||||
valid_mask: np.ndarray | None,
|
||||
interval: float,
|
||||
min_interval: float = 0.5,
|
||||
scene_center: tuple[float, float, float] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""정규 표고 격자로부터 등고선 라인을 추출한다."""
|
||||
interval = max(interval, min_interval)
|
||||
finite_mask = np.isfinite(z_grid)
|
||||
if valid_mask is not None:
|
||||
finite_mask &= valid_mask
|
||||
if not finite_mask.any():
|
||||
return []
|
||||
|
||||
z_min = float(np.min(z_grid[finite_mask]))
|
||||
z_max = float(np.max(z_grid[finite_mask]))
|
||||
start_level = np.ceil(z_min / interval) * interval
|
||||
levels = np.arange(start_level, z_max, interval)
|
||||
if len(levels) == 0:
|
||||
return []
|
||||
if len(levels) > 500:
|
||||
new_interval = (z_max - z_min) / 100.0
|
||||
levels = np.arange(np.ceil(z_min / new_interval) * new_interval, z_max, new_interval)
|
||||
interval = new_interval
|
||||
|
||||
contours_geojson_list: list[dict[str, Any]] = []
|
||||
|
||||
# marching squares의 NaN 문제 예방: 무효 영역을 sentinel(z_min-1000)로 채운다.
|
||||
z_grid_masked = z_grid.copy()
|
||||
if valid_mask is not None:
|
||||
z_grid_masked[~valid_mask] = z_min - 1000.0
|
||||
invalid_mask = ~np.isfinite(z_grid_masked)
|
||||
if invalid_mask.any():
|
||||
z_grid_masked[invalid_mask] = z_min - 1000.0
|
||||
|
||||
cx, cy, cz = scene_center if scene_center is not None else (0.0, 0.0, 0.0)
|
||||
|
||||
for level in levels:
|
||||
for contour in measure.find_contours(z_grid_masked, level):
|
||||
current_segment: list[list[float]] = []
|
||||
for y_idx, x_idx in contour:
|
||||
x_idx_c = np.clip(x_idx, 0, len(x_coords) - 1)
|
||||
y_idx_c = np.clip(y_idx, 0, len(y_coords) - 1)
|
||||
x_0, x_1 = int(np.floor(x_idx_c)), int(np.ceil(x_idx_c))
|
||||
y_0, y_1 = int(np.floor(y_idx_c)), int(np.ceil(y_idx_c))
|
||||
|
||||
is_valid = True
|
||||
if valid_mask is not None and not (
|
||||
valid_mask[y_0, x_0]
|
||||
and valid_mask[y_0, x_1]
|
||||
and valid_mask[y_1, x_0]
|
||||
and valid_mask[y_1, x_1]
|
||||
):
|
||||
is_valid = False
|
||||
|
||||
if not is_valid:
|
||||
if len(current_segment) >= 2:
|
||||
mid_idx = len(current_segment) // 2
|
||||
contours_geojson_list.append(
|
||||
{
|
||||
"level": float(level),
|
||||
"coordinates": current_segment,
|
||||
"label_position": current_segment[mid_idx],
|
||||
}
|
||||
)
|
||||
current_segment = []
|
||||
continue
|
||||
|
||||
tx = x_idx_c - x_0
|
||||
ty = y_idx_c - y_0
|
||||
x_val = (1.0 - tx) * x_coords[x_0] + tx * x_coords[x_1]
|
||||
y_val = (1.0 - ty) * y_coords[y_0] + ty * y_coords[y_1]
|
||||
|
||||
if scene_center is not None:
|
||||
current_segment.append(
|
||||
[
|
||||
round(float(x_val - cx), 3),
|
||||
round(float(level - cz), 3),
|
||||
round(float(-(y_val - cy)), 3),
|
||||
]
|
||||
)
|
||||
else:
|
||||
current_segment.append(
|
||||
[round(float(x_val), 3), round(float(y_val), 3), round(float(level), 3)]
|
||||
)
|
||||
|
||||
if len(current_segment) >= 2:
|
||||
mid_idx = len(current_segment) // 2
|
||||
contours_geojson_list.append(
|
||||
{
|
||||
"level": float(level),
|
||||
"coordinates": current_segment,
|
||||
"label_position": current_segment[mid_idx],
|
||||
}
|
||||
)
|
||||
|
||||
return contours_geojson_list
|
||||
|
||||
|
||||
def _load_footprint_mask(
|
||||
model_npz_path: Path, x_coords: np.ndarray, y_coords: np.ndarray
|
||||
) -> np.ndarray | None:
|
||||
"""같은 source filter의 DTM valid_mask를 현재 격자에 최근접 리샘플한다."""
|
||||
stem = Path(model_npz_path).stem
|
||||
if stem.endswith("_smooth"):
|
||||
stem = stem[:-7]
|
||||
parts = stem.split("_", 1)
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
filter_key = parts[1]
|
||||
dtm_path = Path(model_npz_path).parent / f"dtm_{filter_key}.npz"
|
||||
if not dtm_path.exists():
|
||||
return None
|
||||
try:
|
||||
d = np.load(dtm_path)
|
||||
dtm_x = np.asarray(d["x"]).ravel()
|
||||
dtm_y = np.asarray(d["y"]).ravel()
|
||||
dtm_mask = np.asarray(d["valid_mask"], dtype=bool)
|
||||
except Exception:
|
||||
return None
|
||||
if len(dtm_x) < 2 or len(dtm_y) < 2:
|
||||
return None
|
||||
|
||||
def _nearest_idx(axis: np.ndarray, coords: np.ndarray) -> np.ndarray:
|
||||
ascending = bool(axis[0] <= axis[-1])
|
||||
a = axis if ascending else axis[::-1]
|
||||
idx = np.clip(np.searchsorted(a, coords), 1, len(a) - 1)
|
||||
idx = np.where(np.abs(a[idx - 1] - coords) <= np.abs(a[idx] - coords), idx - 1, idx)
|
||||
return idx if ascending else (len(axis) - 1 - idx)
|
||||
|
||||
xi = _nearest_idx(dtm_x, np.asarray(x_coords, dtype=np.float64))
|
||||
yi = _nearest_idx(dtm_y, np.asarray(y_coords, dtype=np.float64))
|
||||
return dtm_mask[np.ix_(yi, xi)]
|
||||
|
||||
|
||||
def _apply_footprint(
|
||||
model_npz_path: Path, x_coords: np.ndarray, y_coords: np.ndarray, valid_mask: np.ndarray
|
||||
) -> np.ndarray:
|
||||
"""valid_mask에 DTM footprint를 교집합으로 적용한다 (형상 다르면 최근접 리샘플)."""
|
||||
fp = _load_footprint_mask(model_npz_path, x_coords, y_coords)
|
||||
if fp is not None:
|
||||
if fp.shape == valid_mask.shape:
|
||||
return valid_mask & fp
|
||||
from scipy.ndimage import zoom
|
||||
|
||||
zoom_y = valid_mask.shape[0] / fp.shape[0]
|
||||
zoom_x = valid_mask.shape[1] / fp.shape[1]
|
||||
fp_resized = zoom(fp.astype(float), (zoom_y, zoom_x), order=0) > 0.5
|
||||
if fp_resized.shape == valid_mask.shape:
|
||||
return valid_mask & fp_resized
|
||||
return valid_mask
|
||||
|
||||
|
||||
def _tin_face_coverage_mask(
|
||||
vertices: np.ndarray, faces: np.ndarray, xx: np.ndarray, yy: np.ndarray
|
||||
) -> np.ndarray:
|
||||
"""저장된 TIN 면이 실제로 덮는 XY 영역만 True로 반환한다."""
|
||||
vertices = np.asarray(vertices)
|
||||
faces = np.asarray(faces, dtype=np.int64)
|
||||
if vertices.ndim != 2 or vertices.shape[1] < 2 or not len(faces):
|
||||
return np.zeros(xx.shape, dtype=bool)
|
||||
|
||||
edges = np.vstack((faces[:, [0, 1]], faces[:, [1, 2]], faces[:, [2, 0]]))
|
||||
edges = np.sort(edges, axis=1)
|
||||
unique_edges, counts = np.unique(edges, axis=0, return_counts=True)
|
||||
boundary_edges = unique_edges[counts == 1]
|
||||
if not len(boundary_edges):
|
||||
return np.zeros(xx.shape, dtype=bool)
|
||||
|
||||
from shapely import get_parts, intersects_xy, linestrings, polygonize, union_all
|
||||
|
||||
boundary_lines = linestrings(vertices[boundary_edges, :2])
|
||||
polygons = list(get_parts(polygonize(boundary_lines)))
|
||||
if not polygons:
|
||||
return np.zeros(xx.shape, dtype=bool)
|
||||
coverage = union_all(polygons)
|
||||
xx_flat = np.asarray(xx, dtype=np.float64).ravel()
|
||||
yy_flat = np.asarray(yy, dtype=np.float64).ravel()
|
||||
res_flat = np.asarray(intersects_xy(coverage, xx_flat, yy_flat), dtype=bool)
|
||||
return res_flat.reshape(xx.shape)
|
||||
|
||||
|
||||
def _grid_axes(x_min: float, x_max: float, y_min: float, y_max: float, target_grid_m: float):
|
||||
cols = max(2, int(np.ceil((x_max - x_min) / target_grid_m)) + 1)
|
||||
rows = max(2, int(np.ceil((y_max - y_min) / target_grid_m)) + 1)
|
||||
x_coords = np.linspace(x_min, x_max, cols, dtype=np.float32)
|
||||
y_coords = np.linspace(y_min, y_max, rows, dtype=np.float32)
|
||||
return x_coords, y_coords
|
||||
|
||||
|
||||
def extract_contours(
|
||||
model_npz_path: Path,
|
||||
representation: str,
|
||||
interval: float,
|
||||
target_grid_m: float = 1.0,
|
||||
scene_center: tuple[float, float, float] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""표현별 npz 모델에서 표고 격자를 환원한 뒤 등고선 리스트를 추출한다."""
|
||||
model_npz_path = Path(model_npz_path)
|
||||
if not model_npz_path.exists():
|
||||
raise FileNotFoundError(f"모델 파일이 존재하지 않습니다: {model_npz_path}")
|
||||
data = np.load(model_npz_path)
|
||||
|
||||
if representation == "regular_grid":
|
||||
x_coords, y_coords, z_grid, valid_mask = (
|
||||
data["x"],
|
||||
data["y"],
|
||||
data["z"],
|
||||
data["valid_mask"],
|
||||
)
|
||||
current_res = (x_coords[-1] - x_coords[0]) / (len(x_coords) - 1)
|
||||
step = max(1, int(round(target_grid_m / current_res)))
|
||||
if step > 1:
|
||||
return extract_contours_from_grid(
|
||||
x_coords[::step],
|
||||
y_coords[::step],
|
||||
z_grid[::step, ::step],
|
||||
valid_mask[::step, ::step],
|
||||
interval,
|
||||
scene_center=scene_center,
|
||||
)
|
||||
return extract_contours_from_grid(
|
||||
x_coords, y_coords, z_grid, valid_mask, interval, scene_center=scene_center
|
||||
)
|
||||
|
||||
if representation == "triangular_mesh":
|
||||
from scipy.interpolate import griddata
|
||||
|
||||
vertices, faces = data["vertices"], data["faces"]
|
||||
x_min, x_max = float(np.min(vertices[:, 0])), float(np.max(vertices[:, 0]))
|
||||
y_min, y_max = float(np.min(vertices[:, 1])), float(np.max(vertices[:, 1]))
|
||||
x_coords, y_coords = _grid_axes(x_min, x_max, y_min, y_max, target_grid_m)
|
||||
xx, yy = np.meshgrid(x_coords, y_coords)
|
||||
z_grid = griddata(vertices[:, :2], vertices[:, 2], (xx, yy), method="linear")
|
||||
face_mask = _tin_face_coverage_mask(vertices, faces, xx, yy)
|
||||
valid_mask = np.isfinite(z_grid) & face_mask
|
||||
valid_mask = _apply_footprint(model_npz_path, x_coords, y_coords, valid_mask)
|
||||
return extract_contours_from_grid(
|
||||
x_coords, y_coords, z_grid, valid_mask, interval, scene_center=scene_center
|
||||
)
|
||||
|
||||
if representation == "bspline_surface":
|
||||
control_x, control_y, control_z = data["control_x"], data["control_y"], data["control_z"]
|
||||
degree = int(data["degree"][0])
|
||||
spline = RectBivariateSpline(
|
||||
control_y,
|
||||
control_x,
|
||||
control_z,
|
||||
kx=min(degree, len(control_y) - 1),
|
||||
ky=min(degree, len(control_x) - 1),
|
||||
s=float(len(control_x) * len(control_y)) * 0.01,
|
||||
)
|
||||
x_coords, y_coords = _grid_axes(
|
||||
float(control_x[0]),
|
||||
float(control_x[-1]),
|
||||
float(control_y[0]),
|
||||
float(control_y[-1]),
|
||||
target_grid_m,
|
||||
)
|
||||
z_grid = np.asarray(spline(y_coords, x_coords), dtype=np.float32)
|
||||
valid_mask = _apply_footprint(
|
||||
model_npz_path, x_coords, y_coords, np.ones_like(z_grid, dtype=bool)
|
||||
)
|
||||
return extract_contours_from_grid(
|
||||
x_coords, y_coords, z_grid, valid_mask, interval, scene_center=scene_center
|
||||
)
|
||||
|
||||
if representation == "local_rbf_height_field":
|
||||
centers_xy, center_z = data["centers_xy"], data["center_z"]
|
||||
smoothing = float(data["smoothing"][0])
|
||||
interpolator = RBFInterpolator(
|
||||
centers_xy.astype(np.float64),
|
||||
center_z.astype(np.float64),
|
||||
neighbors=min(64, len(centers_xy)),
|
||||
smoothing=smoothing,
|
||||
kernel="thin_plate_spline",
|
||||
)
|
||||
x_min, x_max = float(np.min(centers_xy[:, 0])), float(np.max(centers_xy[:, 0]))
|
||||
y_min, y_max = float(np.min(centers_xy[:, 1])), float(np.max(centers_xy[:, 1]))
|
||||
x_coords, y_coords = _grid_axes(x_min, x_max, y_min, y_max, target_grid_m)
|
||||
xx, yy = np.meshgrid(x_coords, y_coords)
|
||||
z_values = interpolator(np.column_stack([xx.ravel(), yy.ravel()])).astype(np.float32)
|
||||
z_grid = z_values.reshape(len(y_coords), len(x_coords))
|
||||
valid_mask = _apply_footprint(
|
||||
model_npz_path, x_coords, y_coords, np.ones_like(z_grid, dtype=bool)
|
||||
)
|
||||
return extract_contours_from_grid(
|
||||
x_coords, y_coords, z_grid, valid_mask, interval, scene_center=scene_center
|
||||
)
|
||||
|
||||
if representation == "meshfree_surfels":
|
||||
from scipy.interpolate import griddata
|
||||
from scipy.spatial import Delaunay
|
||||
|
||||
points = data["points"]
|
||||
x_min, x_max = float(np.min(points[:, 0])), float(np.max(points[:, 0]))
|
||||
y_min, y_max = float(np.min(points[:, 1])), float(np.max(points[:, 1]))
|
||||
x_coords, y_coords = _grid_axes(x_min, x_max, y_min, y_max, target_grid_m)
|
||||
xx, yy = np.meshgrid(x_coords, y_coords)
|
||||
z_grid = griddata(points[:, :2], points[:, 2], (xx, yy), method="linear")
|
||||
valid_mask = np.isfinite(z_grid)
|
||||
try:
|
||||
tri = Delaunay(points[:, :2])
|
||||
hull_inside = tri.find_simplex(np.column_stack([xx.ravel(), yy.ravel()])) >= 0
|
||||
valid_mask = valid_mask & hull_inside.reshape(xx.shape)
|
||||
except Exception:
|
||||
pass
|
||||
valid_mask = _apply_footprint(model_npz_path, x_coords, y_coords, valid_mask)
|
||||
return extract_contours_from_grid(
|
||||
x_coords, y_coords, z_grid, valid_mask, interval, scene_center=scene_center
|
||||
)
|
||||
|
||||
raise ValueError(f"지원하지 않는 표현 방식입니다: {representation}")
|
||||
@@ -0,0 +1,110 @@
|
||||
"""B04 CSF(Cloth Simulation Filter) 지면 필터.
|
||||
|
||||
Pure NumPy 기반 CSF. 지형을 반전시킨 뒤 가상의 천을 중력으로 낙하시켜
|
||||
원 지면(반전 최하단)에 밀착시키고, 천과의 오차가 임계값 이내인 포인트를
|
||||
지면으로 분류한다.
|
||||
"""
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from config.config_system import (
|
||||
SURFACE_CSF_CLASS_THRESHOLD_M,
|
||||
SURFACE_CSF_CLOTH_RESOLUTION_M,
|
||||
SURFACE_CSF_ITERATIONS,
|
||||
SURFACE_CSF_RIGIDNESS,
|
||||
SURFACE_CSF_SLOPE_SMOOTH,
|
||||
SURFACE_CSF_SLOPE_SMOOTH_THRESHOLD_M,
|
||||
SURFACE_CSF_TIME_STEP,
|
||||
)
|
||||
|
||||
# rigidness 단계별 스프링 완화 계수 (1: 산악 밀착 ~ 3: 부드럽게 덮음)
|
||||
_RIGIDNESS_SPRING_COEFF = {1: 0.25, 2: 0.45, 3: 0.65}
|
||||
# 중력 하강 계수 (time_step에 곱해 반복당 낙하량 산출)
|
||||
_GRAVITY_BASE = 9.8 * 0.05
|
||||
|
||||
|
||||
def filter_csf(
|
||||
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
|
||||
cloth_resolution: float = SURFACE_CSF_CLOTH_RESOLUTION_M,
|
||||
rigidness: int = SURFACE_CSF_RIGIDNESS,
|
||||
time_step: float = SURFACE_CSF_TIME_STEP,
|
||||
class_threshold: float = SURFACE_CSF_CLASS_THRESHOLD_M,
|
||||
iterations: int = SURFACE_CSF_ITERATIONS,
|
||||
slope_smooth: bool = SURFACE_CSF_SLOPE_SMOOTH,
|
||||
slope_smooth_threshold: float = SURFACE_CSF_SLOPE_SMOOTH_THRESHOLD_M,
|
||||
) -> np.ndarray:
|
||||
"""CSF로 지면 포인트의 불리언 마스크를 반환한다."""
|
||||
if not math.isfinite(cloth_resolution) or cloth_resolution <= 0:
|
||||
raise ValueError("천 해상도는 0보다 큰 유한한 값이어야 합니다.")
|
||||
if rigidness not in _RIGIDNESS_SPRING_COEFF:
|
||||
raise ValueError("rigidness는 1, 2, 3 중 하나여야 합니다.")
|
||||
if not math.isfinite(time_step) or time_step <= 0:
|
||||
raise ValueError("time_step은 0보다 큰 유한한 값이어야 합니다.")
|
||||
if not math.isfinite(class_threshold) or class_threshold < 0:
|
||||
raise ValueError("분류 임계값은 0 이상의 유한한 값이어야 합니다.")
|
||||
if iterations <= 0:
|
||||
raise ValueError("반복 횟수는 1 이상이어야 합니다.")
|
||||
|
||||
xyz = np.asarray(structured_data["xyz"], dtype=np.float64)
|
||||
if xyz.ndim != 2 or xyz.shape[1] != 3:
|
||||
raise ValueError("xyz 배열은 (N, 3) 형태여야 합니다.")
|
||||
if xyz.shape[0] == 0:
|
||||
return np.zeros(0, dtype=bool)
|
||||
|
||||
xs, ys, zs = xyz[:, 0], xyz[:, 1], xyz[:, 2]
|
||||
|
||||
# 1. 지형 반전 — 지표면 추출을 위해 높이를 뒤집는다.
|
||||
z_max = float(np.max(zs))
|
||||
inverted_zs = (z_max - zs).astype(np.float32)
|
||||
|
||||
# 2. 2D 가상 천 격자 설정 (바운더리 밀착 매핑)
|
||||
x_min, x_max = float(np.min(xs)), float(np.max(xs))
|
||||
y_min, y_max = float(np.min(ys)), float(np.max(ys))
|
||||
cols = int(np.ceil((x_max - x_min) / cloth_resolution)) + 1
|
||||
rows = int(np.ceil((y_max - y_min) / cloth_resolution)) + 1
|
||||
|
||||
# 천 노드 초기 높이 — 반전 지형 최고점보다 약간 높은 곳에서 낙하 시작
|
||||
start_height = float(np.max(inverted_zs)) + 1.0
|
||||
cloth_z = np.full((rows, cols), start_height, dtype=np.float32)
|
||||
|
||||
# 3. 격자 충돌 타겟 구성 (Drape Target)
|
||||
collision_grid = np.full((rows, cols), -np.inf, dtype=np.float32)
|
||||
gx = np.clip(((xs - x_min) / cloth_resolution).astype(np.int32), 0, cols - 1)
|
||||
gy = np.clip(((ys - y_min) / cloth_resolution).astype(np.int32), 0, rows - 1)
|
||||
np.maximum.at(collision_grid, (gy, gx), inverted_zs)
|
||||
collision_grid[collision_grid == -np.inf] = 0.0
|
||||
|
||||
# 4. 천 시뮬레이션 반복 루프 (물리 하강)
|
||||
gravity = _GRAVITY_BASE * time_step
|
||||
spring_coeff = _RIGIDNESS_SPRING_COEFF[rigidness]
|
||||
for _ in range(iterations):
|
||||
cloth_z -= gravity
|
||||
cloth_z = np.maximum(cloth_z, collision_grid)
|
||||
|
||||
# 노드 간 스프링 제약 완화 (가로/세로 인접 교정)
|
||||
diff_h = cloth_z[:, 1:] - cloth_z[:, :-1]
|
||||
correction_h = diff_h * spring_coeff * 0.5
|
||||
cloth_z[:, :-1] += correction_h
|
||||
cloth_z[:, 1:] -= correction_h
|
||||
|
||||
diff_v = cloth_z[1:, :] - cloth_z[:-1, :]
|
||||
correction_v = diff_v * spring_coeff * 0.5
|
||||
cloth_z[:-1, :] += correction_v
|
||||
cloth_z[1:, :] -= correction_v
|
||||
|
||||
cloth_z = np.maximum(cloth_z, collision_grid)
|
||||
|
||||
# 5. 시뮬레이션 천 높이와 원본 대조 → 오차 이내면 지면
|
||||
simulated_inverted_z = cloth_z[gy, gx]
|
||||
height_diff = np.abs(inverted_zs - simulated_inverted_z)
|
||||
mask = height_diff <= class_threshold
|
||||
|
||||
# 6. 수목 노이즈 2차 필터 보정
|
||||
if slope_smooth:
|
||||
local_min_z = collision_grid[gy, gx]
|
||||
mask = mask & ((inverted_zs - local_min_z) < slope_smooth_threshold)
|
||||
|
||||
return np.asarray(mask, dtype=bool)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""B04 grid minimum-Z 지면 필터."""
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from config.config_system import SURFACE_GRID_CELL_SIZE_M, SURFACE_GRID_HEIGHT_THRESHOLD_M
|
||||
|
||||
|
||||
def filter_grid_min_z(
|
||||
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
|
||||
cell_size: float = SURFACE_GRID_CELL_SIZE_M,
|
||||
height_threshold: float = SURFACE_GRID_HEIGHT_THRESHOLD_M,
|
||||
) -> np.ndarray:
|
||||
"""격자 최저 표면에서 높이 임계값 이내인 포인트의 불리언 마스크를 반환한다."""
|
||||
if not math.isfinite(cell_size) or cell_size <= 0:
|
||||
raise ValueError("격자 크기는 0보다 큰 유한한 값이어야 합니다.")
|
||||
if not math.isfinite(height_threshold) or height_threshold < 0:
|
||||
raise ValueError("높이 임계값은 0 이상의 유한한 값이어야 합니다.")
|
||||
|
||||
xyz = np.asarray(structured_data["xyz"], dtype=np.float64)
|
||||
bounds = np.asarray(structured_data["bounds"], dtype=np.float64)
|
||||
if xyz.ndim != 2 or xyz.shape[1] != 3:
|
||||
raise ValueError("xyz 배열은 (N, 3) 형태여야 합니다.")
|
||||
if bounds.shape != (3, 2):
|
||||
raise ValueError("bounds 배열은 (3, 2) 형태여야 합니다.")
|
||||
if xyz.shape[0] == 0:
|
||||
return np.zeros(0, dtype=bool)
|
||||
|
||||
x_min, y_min, z_min = bounds[0, 0], bounds[1, 0], bounds[2, 0]
|
||||
x_max, y_max = bounds[0, 1], bounds[1, 1]
|
||||
grid_width = int(np.ceil((x_max - x_min) / cell_size)) + 2
|
||||
grid_height = int(np.ceil((y_max - y_min) / cell_size)) + 2
|
||||
minimum_z = np.full((grid_height, grid_width), np.inf, dtype=np.float32)
|
||||
|
||||
grid_x = np.clip(((xyz[:, 0] - x_min) / cell_size).astype(np.int64), 0, grid_width - 1)
|
||||
grid_y = np.clip(((xyz[:, 1] - y_min) / cell_size).astype(np.int64), 0, grid_height - 1)
|
||||
np.minimum.at(minimum_z, (grid_y, grid_x), xyz[:, 2])
|
||||
minimum_z[np.isinf(minimum_z)] = z_min
|
||||
|
||||
try:
|
||||
from scipy.ndimage import minimum_filter
|
||||
|
||||
minimum_z = minimum_filter(minimum_z, size=3).astype(np.float32)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
height_above = xyz[:, 2] - minimum_z[grid_y, grid_x]
|
||||
return np.asarray(
|
||||
(height_above >= 0.0) & (height_above <= height_threshold),
|
||||
dtype=bool,
|
||||
)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""B04 PMF(Progressive Morphological Filter) 지면 필터.
|
||||
|
||||
XY 평면을 격자로 투영해 Z-min 지형 맵을 만든 뒤, 윈도우 폭을 단계적으로
|
||||
키워가며 형태학적 열림(Opening) 연산으로 수목·구조물을 제거하고, 최종 지면
|
||||
대비 높이차가 임계값 이내인 포인트를 지면으로 분류한다. Pure NumPy 구현.
|
||||
"""
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from numpy.lib.stride_tricks import sliding_window_view
|
||||
|
||||
from config.config_system import (
|
||||
SURFACE_PMF_CELL_SIZE_M,
|
||||
SURFACE_PMF_INITIAL_WINDOW_SIZE,
|
||||
SURFACE_PMF_MAX_DISTANCE_M,
|
||||
SURFACE_PMF_MAX_WINDOW_SIZE,
|
||||
SURFACE_PMF_SLOPE,
|
||||
)
|
||||
|
||||
|
||||
def _min_max_window_filter(grid: np.ndarray, w_size: int, mode: str = "min") -> np.ndarray:
|
||||
"""순수 NumPy 2D 이동 윈도우 최솟값/최댓값 필터 (경계는 edge 패딩)."""
|
||||
pad_val = w_size // 2
|
||||
padded = np.pad(grid, pad_val, mode="edge")
|
||||
windows = sliding_window_view(padded, (w_size, w_size))
|
||||
if mode == "min":
|
||||
return np.min(windows, axis=(2, 3))
|
||||
return np.max(windows, axis=(2, 3))
|
||||
|
||||
|
||||
def filter_pmf(
|
||||
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
|
||||
cell_size: float = SURFACE_PMF_CELL_SIZE_M,
|
||||
max_window_size: int = SURFACE_PMF_MAX_WINDOW_SIZE,
|
||||
slope: float = SURFACE_PMF_SLOPE,
|
||||
initial_window_size: int = SURFACE_PMF_INITIAL_WINDOW_SIZE,
|
||||
max_distance: float = SURFACE_PMF_MAX_DISTANCE_M,
|
||||
) -> np.ndarray:
|
||||
"""PMF로 지면 포인트의 불리언 마스크를 반환한다."""
|
||||
if not math.isfinite(cell_size) or cell_size <= 0:
|
||||
raise ValueError("격자 크기는 0보다 큰 유한한 값이어야 합니다.")
|
||||
if initial_window_size < 1 or max_window_size < initial_window_size:
|
||||
raise ValueError("윈도우 크기는 1 이상이고 최대가 초기값 이상이어야 합니다.")
|
||||
if not math.isfinite(max_distance) or max_distance < 0:
|
||||
raise ValueError("최대 거리 임계값은 0 이상의 유한한 값이어야 합니다.")
|
||||
|
||||
xyz = np.asarray(structured_data["xyz"], dtype=np.float64)
|
||||
bounds = np.asarray(structured_data["bounds"], dtype=np.float64)
|
||||
if xyz.ndim != 2 or xyz.shape[1] != 3:
|
||||
raise ValueError("xyz 배열은 (N, 3) 형태여야 합니다.")
|
||||
if bounds.shape != (3, 2):
|
||||
raise ValueError("bounds 배열은 (3, 2) 형태여야 합니다.")
|
||||
if xyz.shape[0] == 0:
|
||||
return np.zeros(0, dtype=bool)
|
||||
|
||||
xs, ys, zs = xyz[:, 0], xyz[:, 1], xyz[:, 2]
|
||||
x_min, y_min, z_min = bounds[0, 0], bounds[1, 0], bounds[2, 0]
|
||||
x_max, y_max = bounds[0, 1], bounds[1, 1]
|
||||
|
||||
grid_w = int(np.ceil((x_max - x_min) / cell_size)) + 2
|
||||
grid_h = int(np.ceil((y_max - y_min) / cell_size)) + 2
|
||||
|
||||
z_grid = np.full((grid_h, grid_w), np.inf, dtype=np.float32)
|
||||
gx = np.clip(((xs - x_min) / cell_size).astype(np.int32), 0, grid_w - 1)
|
||||
gy = np.clip(((ys - y_min) / cell_size).astype(np.int32), 0, grid_h - 1)
|
||||
|
||||
# 1. Z-min 지형 구성
|
||||
np.minimum.at(z_grid, (gy, gx), zs.astype(np.float32))
|
||||
z_grid[z_grid == np.inf] = z_min
|
||||
|
||||
# 2. 점진적 형태학 필터 (Opening = Dilation of Erosion)
|
||||
current_grid = z_grid.copy()
|
||||
w_sizes = []
|
||||
w = initial_window_size
|
||||
while w <= max_window_size:
|
||||
w_sizes.append(w)
|
||||
w = w * 2 + 1
|
||||
|
||||
for w_size in w_sizes:
|
||||
eroded = _min_max_window_filter(current_grid, w_size, mode="min")
|
||||
opened = _min_max_window_filter(eroded, w_size, mode="max")
|
||||
t_dist = min(slope * w_size * cell_size * 0.15 + 0.5, max_distance)
|
||||
mask_elev = (current_grid - opened) > t_dist
|
||||
current_grid[mask_elev] = opened[mask_elev]
|
||||
|
||||
# 3. 마스크 매핑 및 원본 비교
|
||||
simulated_z = current_grid[gy, gx]
|
||||
mask = (zs >= simulated_z - 0.4) & (zs <= simulated_z + max_distance)
|
||||
return np.asarray(mask, dtype=bool)
|
||||
@@ -0,0 +1,116 @@
|
||||
"""B04 RANSAC 지면 필터 (Local plane fitting).
|
||||
|
||||
공간을 로컬 격자로 분할하고, 각 격자에서 RANSAC 평면 피팅을 수행해
|
||||
평면과의 거리가 임계값 이내인 인라이어(지면)를 취합한다. Pure NumPy 구현.
|
||||
"""
|
||||
|
||||
import math
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from config.config_system import (
|
||||
SURFACE_RANSAC_DISTANCE_THRESHOLD_M,
|
||||
SURFACE_RANSAC_ITERATIONS,
|
||||
SURFACE_RANSAC_LOCAL_GRID_SIZE_M,
|
||||
SURFACE_RANSAC_N,
|
||||
SURFACE_RANSAC_SEED,
|
||||
)
|
||||
|
||||
|
||||
def fit_plane_ransac(
|
||||
points: np.ndarray,
|
||||
distance_threshold: float = SURFACE_RANSAC_DISTANCE_THRESHOLD_M,
|
||||
ransac_n: int = SURFACE_RANSAC_N,
|
||||
num_iterations: int = SURFACE_RANSAC_ITERATIONS,
|
||||
seed: int = SURFACE_RANSAC_SEED,
|
||||
) -> np.ndarray:
|
||||
"""평면 ax+by+cz+d=0을 RANSAC으로 피팅하고 인라이어 마스크를 반환한다."""
|
||||
n_points = len(points)
|
||||
if n_points < ransac_n:
|
||||
return np.ones(n_points, dtype=bool)
|
||||
|
||||
best_inliers = np.zeros(n_points, dtype=bool)
|
||||
max_inlier_count = -1
|
||||
rng = np.random.default_rng(seed)
|
||||
|
||||
for _ in range(num_iterations):
|
||||
idx = rng.choice(n_points, ransac_n, replace=False)
|
||||
p0, p1, p2 = points[idx]
|
||||
normal = np.cross(p1 - p0, p2 - p0)
|
||||
norm = np.linalg.norm(normal)
|
||||
if norm < 1e-6:
|
||||
continue # 세 점이 일직선상 → 스킵
|
||||
normal = normal / norm
|
||||
d = -np.dot(normal, p0)
|
||||
distances = np.abs(np.dot(points, normal) + d)
|
||||
inliers = distances < distance_threshold
|
||||
inlier_count = int(np.sum(inliers))
|
||||
if inlier_count > max_inlier_count:
|
||||
max_inlier_count = inlier_count
|
||||
best_inliers = inliers
|
||||
|
||||
if max_inlier_count > 0:
|
||||
return best_inliers
|
||||
return np.ones(n_points, dtype=bool)
|
||||
|
||||
|
||||
def filter_ransac(
|
||||
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
|
||||
distance_threshold: float = SURFACE_RANSAC_DISTANCE_THRESHOLD_M,
|
||||
ransac_n: int = SURFACE_RANSAC_N,
|
||||
num_iterations: int = SURFACE_RANSAC_ITERATIONS,
|
||||
local_grid_size: float = SURFACE_RANSAC_LOCAL_GRID_SIZE_M,
|
||||
seed: int = SURFACE_RANSAC_SEED,
|
||||
progress_callback: Callable[[int], None] | None = None,
|
||||
) -> np.ndarray:
|
||||
"""로컬 격자별 RANSAC 평면 분할로 지면 포인트 마스크를 반환한다."""
|
||||
if not math.isfinite(local_grid_size) or local_grid_size <= 0:
|
||||
raise ValueError("로컬 격자 크기는 0보다 큰 유한한 값이어야 합니다.")
|
||||
if ransac_n < 3:
|
||||
raise ValueError("RANSAC 샘플 수는 3 이상이어야 합니다.")
|
||||
|
||||
xyz = np.asarray(structured_data["xyz"], dtype=np.float64)
|
||||
bounds = np.asarray(structured_data["bounds"], dtype=np.float64)
|
||||
if xyz.ndim != 2 or xyz.shape[1] != 3:
|
||||
raise ValueError("xyz 배열은 (N, 3) 형태여야 합니다.")
|
||||
if bounds.shape != (3, 2):
|
||||
raise ValueError("bounds 배열은 (3, 2) 형태여야 합니다.")
|
||||
|
||||
n_points = len(xyz)
|
||||
mask = np.zeros(n_points, dtype=bool)
|
||||
if n_points == 0:
|
||||
return mask
|
||||
|
||||
x_min, y_min = bounds[0, 0], bounds[1, 0]
|
||||
x_max, y_max = bounds[0, 1], bounds[1, 1]
|
||||
grid_w = int(np.ceil((x_max - x_min) / local_grid_size)) + 1
|
||||
grid_h = int(np.ceil((y_max - y_min) / local_grid_size)) + 1
|
||||
|
||||
xs, ys = xyz[:, 0], xyz[:, 1]
|
||||
gx = np.clip(((xs - x_min) / local_grid_size).astype(np.int32), 0, grid_w - 1)
|
||||
gy = np.clip(((ys - y_min) / local_grid_size).astype(np.int32), 0, grid_h - 1)
|
||||
grid_indices = gy * grid_w + gx
|
||||
unique_grids = np.unique(grid_indices)
|
||||
total_grids = len(unique_grids)
|
||||
|
||||
for i, grid_id in enumerate(unique_grids):
|
||||
cell_points_idx = np.where(grid_indices == grid_id)[0]
|
||||
if len(cell_points_idx) < ransac_n:
|
||||
mask[cell_points_idx] = True
|
||||
continue
|
||||
cell_inliers = fit_plane_ransac(
|
||||
xyz[cell_points_idx],
|
||||
distance_threshold=distance_threshold,
|
||||
ransac_n=ransac_n,
|
||||
num_iterations=num_iterations,
|
||||
seed=seed,
|
||||
)
|
||||
mask[cell_points_idx[cell_inliers]] = True
|
||||
if progress_callback:
|
||||
progress_callback(int(((i + 1) / total_grids) * 100))
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(100) # 모든 격자 처리 완료 보장
|
||||
return mask
|
||||
@@ -0,0 +1,64 @@
|
||||
"""B04 지면 필터 오케스트레이션.
|
||||
|
||||
구조화된 포인트클라우드(structured.npz)에 대해 grid_min_z/csf/pmf/ransac
|
||||
필터를 실행하여 지면 마스크 딕셔너리를 만든다. 필터 선택은 config의
|
||||
source_filters를 따른다.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Filter_CSF import filter_csf
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Filter_Grid import filter_grid_min_z
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Filter_PMF import filter_pmf
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Filter_RANSAC import filter_ransac
|
||||
|
||||
# 필터 키 → 함수 매핑
|
||||
_FILTERS = {
|
||||
"grid_min_z": filter_grid_min_z,
|
||||
"csf": filter_csf,
|
||||
"pmf": filter_pmf,
|
||||
"ransac": filter_ransac,
|
||||
}
|
||||
|
||||
|
||||
def available_filters() -> tuple[str, ...]:
|
||||
return tuple(_FILTERS.keys())
|
||||
|
||||
|
||||
def run_ground_filter(
|
||||
filter_key: str, structured_data: dict[str, Any] | np.lib.npyio.NpzFile
|
||||
) -> np.ndarray:
|
||||
"""단일 지면 필터를 실행해 불리언 마스크를 반환한다."""
|
||||
if filter_key not in _FILTERS:
|
||||
raise ValueError(f"알 수 없는 지면 필터입니다: {filter_key}")
|
||||
return np.asarray(_FILTERS[filter_key](structured_data), dtype=bool)
|
||||
|
||||
|
||||
def build_ground_masks(
|
||||
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
|
||||
filter_keys: tuple[str, ...] | list[str],
|
||||
) -> dict[str, np.ndarray]:
|
||||
"""지정한 필터들을 실행해 {filter_key: mask} 딕셔너리를 만든다."""
|
||||
masks: dict[str, np.ndarray] = {}
|
||||
for filter_key in filter_keys:
|
||||
masks[filter_key] = run_ground_filter(filter_key, structured_data)
|
||||
return masks
|
||||
|
||||
|
||||
def summarize_masks(
|
||||
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
|
||||
masks: dict[str, np.ndarray],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""각 필터 마스크의 지면 포인트 수·비율 요약을 만든다."""
|
||||
total = int(len(structured_data["xyz"]))
|
||||
summary: dict[str, dict[str, Any]] = {}
|
||||
for filter_key, mask in masks.items():
|
||||
ground = int(np.count_nonzero(mask))
|
||||
summary[filter_key] = {
|
||||
"ground_point_count": ground,
|
||||
"total_point_count": total,
|
||||
"ground_ratio": round(ground / total, 4) if total else 0.0,
|
||||
}
|
||||
return summary
|
||||
@@ -0,0 +1,284 @@
|
||||
"""B04 지표면 5종 표현 빌더 (TIN/DTM/NURBS/implicit/meshfree).
|
||||
|
||||
TerrainContext에서 파생 격자·샘플을 받아 각 표현의 모델(npz)과 프리뷰
|
||||
(GLB/PLY)를 저장하고 메타데이터를 반환한다.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from scipy.interpolate import RBFInterpolator, RectBivariateSpline
|
||||
from scipy.spatial import Delaunay
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_ModelContext import (
|
||||
ProgressCallback,
|
||||
TerrainContext,
|
||||
artifact_size,
|
||||
atomic_npz,
|
||||
clip_and_compact_mesh,
|
||||
grid_faces,
|
||||
grid_vertices,
|
||||
with_footprint,
|
||||
write_binary_ply,
|
||||
write_glb,
|
||||
)
|
||||
|
||||
|
||||
def build_tin(
|
||||
context: TerrainContext, output_dir: Path, stem: str, progress: ProgressCallback
|
||||
) -> dict[str, Any]:
|
||||
progress(5)
|
||||
points = context.sample(int(context.config["tin_max_input_points"]))
|
||||
unique_xy, unique_indices = np.unique(points[:, :2], axis=0, return_index=True)
|
||||
points = points[unique_indices]
|
||||
if len(points) < 3:
|
||||
raise ValueError("TIN 생성에 필요한 포인트가 부족합니다.")
|
||||
progress(25)
|
||||
faces = np.asarray(Delaunay(unique_xy).simplices, dtype=np.uint32)
|
||||
if len(faces):
|
||||
triangle_xy = points[faces, :2]
|
||||
edges = np.stack(
|
||||
[
|
||||
np.linalg.norm(triangle_xy[:, 0] - triangle_xy[:, 1], axis=1),
|
||||
np.linalg.norm(triangle_xy[:, 1] - triangle_xy[:, 2], axis=1),
|
||||
np.linalg.norm(triangle_xy[:, 2] - triangle_xy[:, 0], axis=1),
|
||||
],
|
||||
axis=1,
|
||||
)
|
||||
faces = faces[np.max(edges, axis=1) <= float(context.config["tile_size_meters"]) * 2]
|
||||
valid_vertices = context.contains_xy(points[:, 0], points[:, 1])
|
||||
points, faces = clip_and_compact_mesh(points, faces, valid_vertices)
|
||||
if not len(faces):
|
||||
raise ValueError("외곽 안쪽 기준 적용 후 TIN 면이 남지 않았습니다.")
|
||||
progress(65)
|
||||
model_path = output_dir / f"{stem}.npz"
|
||||
preview_path = output_dir / f"{stem}_preview.glb"
|
||||
atomic_npz(model_path, vertices=points, faces=faces)
|
||||
write_glb(preview_path, points, faces, context.bounds)
|
||||
progress(100)
|
||||
return with_footprint(
|
||||
context,
|
||||
{
|
||||
"representation": "triangular_mesh",
|
||||
"model_file": model_path.name,
|
||||
"preview_file": preview_path.name,
|
||||
"preview_media_type": "model/gltf-binary",
|
||||
"vertex_count": int(len(points)),
|
||||
"face_count": int(len(faces)),
|
||||
"artifact_bytes": artifact_size(model_path, preview_path),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def build_dtm(
|
||||
context: TerrainContext, output_dir: Path, stem: str, progress: ProgressCallback
|
||||
) -> dict[str, Any]:
|
||||
progress(10)
|
||||
resolution = float(context.config["dtm_grid_resolution_meters"])
|
||||
x_coords, y_coords, z_grid = context.grid(resolution)
|
||||
progress(55)
|
||||
preview_x, preview_y, preview_z = context.preview_grid(resolution)
|
||||
vertices = grid_vertices(preview_x, preview_y, preview_z)
|
||||
faces = grid_faces(len(preview_y), len(preview_x))
|
||||
valid_grid = context.contains_xy(*np.meshgrid(x_coords, y_coords)).reshape(
|
||||
len(y_coords), len(x_coords)
|
||||
)
|
||||
preview_valid = context.contains_xy(vertices[:, 0], vertices[:, 1])
|
||||
vertices, faces = clip_and_compact_mesh(vertices, faces, preview_valid)
|
||||
model_path = output_dir / f"{stem}.npz"
|
||||
preview_path = output_dir / f"{stem}_preview.glb"
|
||||
atomic_npz(
|
||||
model_path,
|
||||
x=x_coords,
|
||||
y=y_coords,
|
||||
z=z_grid,
|
||||
valid_mask=valid_grid,
|
||||
resolution=np.array([resolution], np.float32),
|
||||
)
|
||||
progress(75)
|
||||
write_glb(preview_path, vertices, faces, context.bounds)
|
||||
progress(100)
|
||||
return with_footprint(
|
||||
context,
|
||||
{
|
||||
"representation": "regular_grid",
|
||||
"model_file": model_path.name,
|
||||
"preview_file": preview_path.name,
|
||||
"preview_media_type": "model/gltf-binary",
|
||||
"grid_rows": int(len(y_coords)),
|
||||
"grid_columns": int(len(x_coords)),
|
||||
"grid_resolution_meters": resolution,
|
||||
"vertex_count": int(len(vertices)),
|
||||
"face_count": int(len(faces)),
|
||||
"artifact_bytes": artifact_size(model_path, preview_path),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def build_nurbs(
|
||||
context: TerrainContext, output_dir: Path, stem: str, progress: ProgressCallback
|
||||
) -> dict[str, Any]:
|
||||
degree = max(1, min(5, int(context.config["nurbs_degree"])))
|
||||
patch_size = float(context.config["nurbs_patch_size_meters"])
|
||||
controls = max(degree + 1, int(context.config["nurbs_control_points_per_axis"]))
|
||||
control_resolution = max(patch_size / max(controls - 1, 1), 0.25)
|
||||
x_control, y_control, z_control = context.grid(control_resolution)
|
||||
progress(30)
|
||||
spline = RectBivariateSpline(
|
||||
y_control,
|
||||
x_control,
|
||||
z_control,
|
||||
kx=min(degree, len(y_control) - 1),
|
||||
ky=min(degree, len(x_control) - 1),
|
||||
s=float(len(x_control) * len(y_control)) * 0.01,
|
||||
)
|
||||
x_preview, y_preview, _ = context.preview_grid(
|
||||
float(context.config["dtm_grid_resolution_meters"])
|
||||
)
|
||||
z_preview = np.asarray(spline(y_preview, x_preview), dtype=np.float32)
|
||||
progress(65)
|
||||
vertices = grid_vertices(x_preview, y_preview, z_preview)
|
||||
faces = grid_faces(len(y_preview), len(x_preview))
|
||||
valid_preview = context.contains_xy(vertices[:, 0], vertices[:, 1])
|
||||
vertices, faces = clip_and_compact_mesh(vertices, faces, valid_preview)
|
||||
model_path = output_dir / f"{stem}.npz"
|
||||
preview_path = output_dir / f"{stem}_preview.glb"
|
||||
atomic_npz(
|
||||
model_path,
|
||||
control_x=x_control,
|
||||
control_y=y_control,
|
||||
control_z=z_control,
|
||||
degree=np.array([degree], np.int16),
|
||||
patch_size_meters=np.array([patch_size], np.float32),
|
||||
)
|
||||
write_glb(preview_path, vertices, faces, context.bounds)
|
||||
progress(100)
|
||||
return with_footprint(
|
||||
context,
|
||||
{
|
||||
"representation": "bspline_surface",
|
||||
"model_file": model_path.name,
|
||||
"preview_file": preview_path.name,
|
||||
"preview_media_type": "model/gltf-binary",
|
||||
"degree": degree,
|
||||
"control_rows": int(len(y_control)),
|
||||
"control_columns": int(len(x_control)),
|
||||
"vertex_count": int(len(vertices)),
|
||||
"face_count": int(len(faces)),
|
||||
"artifact_bytes": artifact_size(model_path, preview_path),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def build_implicit(
|
||||
context: TerrainContext, output_dir: Path, stem: str, progress: ProgressCallback
|
||||
) -> dict[str, Any]:
|
||||
maximum = max(100, int(context.config["implicit_max_points_per_tile"]))
|
||||
points = context.sample(maximum)
|
||||
unique_xy, unique_indices = np.unique(points[:, :2], axis=0, return_index=True)
|
||||
points = points[unique_indices]
|
||||
if len(points) < 4:
|
||||
raise ValueError("Implicit 생성에 필요한 포인트가 부족합니다.")
|
||||
progress(20)
|
||||
interpolator = RBFInterpolator(
|
||||
unique_xy.astype(np.float64),
|
||||
points[:, 2].astype(np.float64),
|
||||
neighbors=min(64, len(points)),
|
||||
smoothing=float(context.config["implicit_smoothing"]),
|
||||
kernel="thin_plate_spline",
|
||||
)
|
||||
x_preview, y_preview, _ = context.preview_grid(
|
||||
float(context.config["dtm_grid_resolution_meters"])
|
||||
)
|
||||
xx, yy = np.meshgrid(x_preview, y_preview)
|
||||
query = np.column_stack([xx.ravel(), yy.ravel()])
|
||||
z_values = np.empty(len(query), dtype=np.float32)
|
||||
for start in range(0, len(query), 50_000):
|
||||
end = min(start + 50_000, len(query))
|
||||
z_values[start:end] = interpolator(query[start:end]).astype(np.float32)
|
||||
progress(25 + int(45 * end / len(query)))
|
||||
z_grid = z_values.reshape(len(y_preview), len(x_preview))
|
||||
vertices = grid_vertices(x_preview, y_preview, z_grid)
|
||||
faces = grid_faces(len(y_preview), len(x_preview))
|
||||
valid_preview = context.contains_xy(vertices[:, 0], vertices[:, 1])
|
||||
vertices, faces = clip_and_compact_mesh(vertices, faces, valid_preview)
|
||||
model_path = output_dir / f"{stem}.npz"
|
||||
preview_path = output_dir / f"{stem}_preview.glb"
|
||||
atomic_npz(
|
||||
model_path,
|
||||
centers_xy=unique_xy.astype(np.float32),
|
||||
center_z=points[:, 2].astype(np.float32),
|
||||
smoothing=np.array([float(context.config["implicit_smoothing"])], np.float32),
|
||||
)
|
||||
write_glb(preview_path, vertices, faces, context.bounds)
|
||||
progress(100)
|
||||
return with_footprint(
|
||||
context,
|
||||
{
|
||||
"representation": "local_rbf_height_field",
|
||||
"model_file": model_path.name,
|
||||
"preview_file": preview_path.name,
|
||||
"preview_media_type": "model/gltf-binary",
|
||||
"center_count": int(len(points)),
|
||||
"vertex_count": int(len(vertices)),
|
||||
"face_count": int(len(faces)),
|
||||
"artifact_bytes": artifact_size(model_path, preview_path),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def build_meshfree(
|
||||
context: TerrainContext, output_dir: Path, stem: str, progress: ProgressCallback
|
||||
) -> dict[str, Any]:
|
||||
points = context.sample(int(context.config["meshfree_max_model_points"]))
|
||||
points = points[context.contains_xy(points[:, 0], points[:, 1])]
|
||||
if not len(points):
|
||||
raise ValueError("외곽 안쪽 기준 적용 후 Meshfree 포인트가 남지 않았습니다.")
|
||||
resolution = float(context.config["dtm_grid_resolution_meters"])
|
||||
x_grid, y_grid, z_grid = context.grid(resolution)
|
||||
dz_dy, dz_dx = np.gradient(z_grid, resolution, resolution)
|
||||
gx = np.clip(np.searchsorted(x_grid, points[:, 0]), 0, len(x_grid) - 1)
|
||||
gy = np.clip(np.searchsorted(y_grid, points[:, 1]), 0, len(y_grid) - 1)
|
||||
normals = np.column_stack([-dz_dx[gy, gx], -dz_dy[gy, gx], np.ones(len(points), np.float32)])
|
||||
normals /= np.maximum(np.linalg.norm(normals, axis=1, keepdims=True), 1e-9)
|
||||
progress(55)
|
||||
preview_max = int(context.config["max_preview_vertices"])
|
||||
if len(points) > preview_max:
|
||||
selection = np.linspace(0, len(points) - 1, preview_max, dtype=np.int64)
|
||||
preview_points, preview_normals = points[selection], normals[selection]
|
||||
else:
|
||||
preview_points, preview_normals = points, normals
|
||||
model_path = output_dir / f"{stem}.npz"
|
||||
preview_path = output_dir / f"{stem}_preview.ply"
|
||||
radius = float(context.config["meshfree_point_radius_meters"])
|
||||
atomic_npz(
|
||||
model_path,
|
||||
points=points,
|
||||
normals=normals.astype(np.float32),
|
||||
radius=np.array([radius], np.float32),
|
||||
)
|
||||
write_binary_ply(preview_path, preview_points, preview_normals, context.bounds)
|
||||
progress(100)
|
||||
return with_footprint(
|
||||
context,
|
||||
{
|
||||
"representation": "meshfree_surfels",
|
||||
"model_file": model_path.name,
|
||||
"preview_file": preview_path.name,
|
||||
"preview_media_type": "application/octet-stream",
|
||||
"point_count": int(len(points)),
|
||||
"preview_point_count": int(len(preview_points)),
|
||||
"point_radius_meters": radius,
|
||||
"artifact_bytes": artifact_size(model_path, preview_path),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
BUILDERS = {
|
||||
"tin": build_tin,
|
||||
"dtm": build_dtm,
|
||||
"nurbs": build_nurbs,
|
||||
"implicit": build_implicit,
|
||||
"meshfree": build_meshfree,
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
"""B04 지표면 모델 공통 컨텍스트 및 메시 유틸리티.
|
||||
|
||||
지면 마스크가 적용된 포인트에서 footprint(외곽), 격자, 프리뷰 격자를 만들고,
|
||||
GLB/PLY 프리뷰 및 npz 모델을 원자적으로 저장하는 공통 기능을 제공한다.
|
||||
5개 표현(TIN/DTM/NURBS/implicit/meshfree) 빌더가 이 컨텍스트를 공유한다.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
import numpy as np
|
||||
import trimesh
|
||||
from scipy import ndimage
|
||||
|
||||
from common_util.common_util_atomic import atomic_write_bytes, atomic_write_npz
|
||||
|
||||
MODEL_VERSION = 1
|
||||
MODEL_METHODS = ("tin", "dtm", "nurbs", "implicit", "meshfree")
|
||||
SOURCE_FILTER_LABELS = {"grid_min_z": "Grid Min-Z", "csf": "CSF", "pmf": "PMF"}
|
||||
ProgressCallback = Callable[[int], None]
|
||||
|
||||
# 대용량 포인트 배치 처리 크기
|
||||
_BATCH_SIZE = 500_000
|
||||
|
||||
|
||||
def config_signature(config: dict[str, Any]) -> str:
|
||||
"""지오메트리 원본과 무관한 등고선·스무딩 설정을 제외한 캐시 서명."""
|
||||
sig_config = {
|
||||
k: v
|
||||
for k, v in config.items()
|
||||
if not k.startswith("contour_") and not k.startswith("smoothing_")
|
||||
}
|
||||
encoded = json.dumps(sig_config, sort_keys=True, default=list).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()[:16]
|
||||
|
||||
|
||||
def bounds_dict(bounds: np.ndarray) -> dict[str, list[float]]:
|
||||
return {
|
||||
"x": [float(bounds[0, 0]), float(bounds[0, 1])],
|
||||
"y": [float(bounds[1, 0]), float(bounds[1, 1])],
|
||||
"z": [float(bounds[2, 0]), float(bounds[2, 1])],
|
||||
}
|
||||
|
||||
|
||||
def scene_vertices(vertices: np.ndarray, bounds: np.ndarray) -> np.ndarray:
|
||||
"""모델 좌표를 뷰어(Y-up) 좌표계로 변환한다."""
|
||||
center = bounds.mean(axis=1)
|
||||
result = np.empty((len(vertices), 3), dtype=np.float32)
|
||||
result[:, 0] = vertices[:, 0] - center[0]
|
||||
result[:, 1] = vertices[:, 2] - center[2]
|
||||
result[:, 2] = -(vertices[:, 1] - center[1])
|
||||
return result
|
||||
|
||||
|
||||
def height_colors(vertices: np.ndarray) -> np.ndarray:
|
||||
"""표고에 따른 그라디언트 정점 색상(RGBA)을 만든다."""
|
||||
if not len(vertices):
|
||||
return np.empty((0, 4), dtype=np.uint8)
|
||||
z = vertices[:, 2]
|
||||
span = max(float(np.max(z) - np.min(z)), 1e-9)
|
||||
t = np.clip((z - np.min(z)) / span, 0.0, 1.0)
|
||||
colors = np.empty((len(vertices), 4), dtype=np.uint8)
|
||||
colors[:, 0] = np.clip(36 + 190 * t, 0, 255).astype(np.uint8)
|
||||
colors[:, 1] = np.clip(86 + 95 * np.sin(t * np.pi), 0, 255).astype(np.uint8)
|
||||
colors[:, 2] = np.clip(128 - 80 * t, 0, 255).astype(np.uint8)
|
||||
colors[:, 3] = 255
|
||||
return colors
|
||||
|
||||
|
||||
def write_glb(path: Path, vertices: np.ndarray, faces: np.ndarray, bounds: np.ndarray) -> None:
|
||||
mesh = trimesh.Trimesh(
|
||||
vertices=scene_vertices(vertices, bounds),
|
||||
faces=np.asarray(faces, dtype=np.int64),
|
||||
vertex_colors=height_colors(vertices),
|
||||
process=False,
|
||||
)
|
||||
payload = mesh.export(file_type="glb")
|
||||
if not isinstance(payload, bytes):
|
||||
raise TypeError("GLB exporter did not return bytes")
|
||||
atomic_write_bytes(path, payload)
|
||||
|
||||
|
||||
def write_binary_ply(
|
||||
path: Path, vertices: np.ndarray, normals: np.ndarray, bounds: np.ndarray
|
||||
) -> None:
|
||||
verts = scene_vertices(vertices, bounds)
|
||||
scene_normals = np.empty_like(normals, dtype=np.float32)
|
||||
scene_normals[:, 0] = normals[:, 0]
|
||||
scene_normals[:, 1] = normals[:, 2]
|
||||
scene_normals[:, 2] = -normals[:, 1]
|
||||
colors = height_colors(vertices)
|
||||
dtype = np.dtype(
|
||||
[
|
||||
("x", "<f4"),
|
||||
("y", "<f4"),
|
||||
("z", "<f4"),
|
||||
("nx", "<f4"),
|
||||
("ny", "<f4"),
|
||||
("nz", "<f4"),
|
||||
("red", "u1"),
|
||||
("green", "u1"),
|
||||
("blue", "u1"),
|
||||
("alpha", "u1"),
|
||||
]
|
||||
)
|
||||
records = np.empty(len(vertices), dtype=dtype)
|
||||
records["x"], records["y"], records["z"] = verts.T
|
||||
records["nx"], records["ny"], records["nz"] = scene_normals.T
|
||||
records["red"], records["green"], records["blue"], records["alpha"] = colors.T
|
||||
header = (
|
||||
"ply\nformat binary_little_endian 1.0\n"
|
||||
f"element vertex {len(vertices)}\n"
|
||||
"property float x\nproperty float y\nproperty float z\n"
|
||||
"property float nx\nproperty float ny\nproperty float nz\n"
|
||||
"property uchar red\nproperty uchar green\nproperty uchar blue\nproperty uchar alpha\n"
|
||||
"end_header\n"
|
||||
).encode("ascii")
|
||||
atomic_write_bytes(path, header + records.tobytes())
|
||||
|
||||
|
||||
def grid_faces(rows: int, cols: int) -> np.ndarray:
|
||||
"""정규 격자의 삼각형 면 인덱스를 만든다."""
|
||||
if rows < 2 or cols < 2:
|
||||
return np.empty((0, 3), dtype=np.uint32)
|
||||
base = np.arange((rows - 1) * (cols - 1), dtype=np.uint32)
|
||||
row = base // (cols - 1)
|
||||
col = base % (cols - 1)
|
||||
top_left = row * cols + col
|
||||
faces = np.empty((len(base) * 2, 3), dtype=np.uint32)
|
||||
faces[0::2] = np.stack([top_left, top_left + cols, top_left + 1], axis=1)
|
||||
faces[1::2] = np.stack([top_left + 1, top_left + cols, top_left + cols + 1], axis=1)
|
||||
return faces
|
||||
|
||||
|
||||
def clip_and_compact_mesh(
|
||||
vertices: np.ndarray, faces: np.ndarray, valid_vertices: np.ndarray
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""footprint 내부 정점만 사용하는 면을 남기고 미사용 정점을 제거한다."""
|
||||
if not len(faces):
|
||||
return np.empty((0, 3), np.float32), np.empty((0, 3), np.uint32)
|
||||
kept_faces = faces[np.all(valid_vertices[faces], axis=1)]
|
||||
if not len(kept_faces):
|
||||
return np.empty((0, 3), np.float32), np.empty((0, 3), np.uint32)
|
||||
used = np.unique(kept_faces)
|
||||
remap = np.full(len(vertices), -1, dtype=np.int64)
|
||||
remap[used] = np.arange(len(used))
|
||||
return vertices[used], remap[kept_faces].astype(np.uint32)
|
||||
|
||||
|
||||
def grid_vertices(x_coords: np.ndarray, y_coords: np.ndarray, z_grid: np.ndarray) -> np.ndarray:
|
||||
xx, yy = np.meshgrid(x_coords, y_coords)
|
||||
return np.column_stack([xx.ravel(), yy.ravel(), z_grid.ravel()]).astype(np.float32)
|
||||
|
||||
|
||||
def artifact_size(*paths: Path) -> int:
|
||||
return int(sum(path.stat().st_size for path in paths if path.exists()))
|
||||
|
||||
|
||||
@dataclass
|
||||
class TerrainContext:
|
||||
"""지면 마스크가 적용된 포인트 집합에서 파생 격자·footprint를 캐싱한다."""
|
||||
|
||||
xyz: np.ndarray
|
||||
mask: np.ndarray
|
||||
bounds: np.ndarray
|
||||
config: dict[str, Any]
|
||||
_indices: np.ndarray | None = None
|
||||
_samples: dict[int, np.ndarray] = field(default_factory=dict)
|
||||
_grids: dict[float, tuple[np.ndarray, np.ndarray, np.ndarray]] = field(default_factory=dict)
|
||||
_footprint: tuple[float, float, float, np.ndarray] | None = None
|
||||
|
||||
@property
|
||||
def source_count(self) -> int:
|
||||
return int(np.count_nonzero(self.mask))
|
||||
|
||||
def indices(self) -> np.ndarray:
|
||||
if self._indices is None:
|
||||
self._indices = np.flatnonzero(self.mask)
|
||||
return self._indices
|
||||
|
||||
def sample(self, maximum: int) -> np.ndarray:
|
||||
maximum = max(3, int(maximum))
|
||||
if maximum in self._samples:
|
||||
return self._samples[maximum]
|
||||
indices = self.indices()
|
||||
if len(indices) > maximum:
|
||||
positions = np.linspace(0, len(indices) - 1, maximum, dtype=np.int64)
|
||||
indices = indices[positions]
|
||||
points = np.asarray(self.xyz[indices], dtype=np.float32)
|
||||
self._samples[maximum] = points
|
||||
return points
|
||||
|
||||
def footprint(self) -> tuple[float, float, float, np.ndarray]:
|
||||
if self._footprint is not None:
|
||||
return self._footprint
|
||||
resolution = max(float(self.config.get("footprint_resolution_meters", 1.0)), 0.1)
|
||||
x_min, x_max = self.bounds[0]
|
||||
y_min, y_max = self.bounds[1]
|
||||
cols = max(2, int(math.ceil((x_max - x_min) / resolution)) + 1)
|
||||
rows = max(2, int(math.ceil((y_max - y_min) / resolution)) + 1)
|
||||
occupied = np.zeros((rows, cols), dtype=bool)
|
||||
indices = self.indices()
|
||||
for start in range(0, len(indices), _BATCH_SIZE):
|
||||
points = np.asarray(self.xyz[indices[start : start + _BATCH_SIZE]], dtype=np.float32)
|
||||
gx = np.clip(((points[:, 0] - x_min) / resolution).astype(np.int32), 0, cols - 1)
|
||||
gy = np.clip(((points[:, 1] - y_min) / resolution).astype(np.int32), 0, rows - 1)
|
||||
occupied[gy, gx] = True
|
||||
if not occupied.any():
|
||||
raise ValueError("기준 필터에 footprint를 만들 포인트가 없습니다.")
|
||||
|
||||
close_cells = max(
|
||||
0,
|
||||
int(math.ceil(float(self.config.get("footprint_gap_close_meters", 1.0)) / resolution)),
|
||||
)
|
||||
footprint = occupied
|
||||
if close_cells:
|
||||
padded = np.pad(footprint, close_cells, mode="constant", constant_values=False)
|
||||
padded = ndimage.binary_closing(
|
||||
padded, structure=np.ones((3, 3), dtype=bool), iterations=close_cells
|
||||
)
|
||||
footprint = padded[close_cells:-close_cells, close_cells:-close_cells]
|
||||
if bool(self.config.get("keep_largest_footprint", True)):
|
||||
labels, component_count = ndimage.label(
|
||||
footprint, structure=np.ones((3, 3), dtype=bool)
|
||||
)
|
||||
if component_count:
|
||||
sizes = np.bincount(labels.ravel())
|
||||
sizes[0] = 0
|
||||
footprint = labels == int(np.argmax(sizes))
|
||||
footprint = ndimage.binary_fill_holes(footprint)
|
||||
|
||||
inset_cells = max(
|
||||
0, int(math.ceil(float(self.config.get("boundary_inset_meters", 1.0)) / resolution))
|
||||
)
|
||||
if inset_cells:
|
||||
footprint = ndimage.binary_erosion(
|
||||
footprint,
|
||||
structure=np.ones((3, 3), dtype=bool),
|
||||
iterations=inset_cells,
|
||||
border_value=0,
|
||||
)
|
||||
if not footprint.any():
|
||||
raise ValueError("외곽 안쪽 기준 적용 후 유효한 footprint가 없습니다.")
|
||||
self._footprint = (float(x_min), float(y_min), resolution, footprint)
|
||||
return self._footprint
|
||||
|
||||
def contains_xy(self, x: np.ndarray, y: np.ndarray) -> np.ndarray:
|
||||
x_min, y_min, resolution, footprint = self.footprint()
|
||||
gx = np.floor((np.asarray(x) - x_min) / resolution).astype(np.int64)
|
||||
gy = np.floor((np.asarray(y) - y_min) / resolution).astype(np.int64)
|
||||
valid = (gx >= 0) & (gx < footprint.shape[1]) & (gy >= 0) & (gy < footprint.shape[0])
|
||||
result = np.zeros(np.broadcast(x, y).shape, dtype=bool)
|
||||
result[valid] = footprint[gy[valid], gx[valid]]
|
||||
return result
|
||||
|
||||
def footprint_metadata(self) -> dict[str, Any]:
|
||||
_, _, resolution, footprint = self.footprint()
|
||||
return {
|
||||
"footprint_area_m2": round(float(footprint.sum()) * resolution * resolution, 3),
|
||||
"footprint_resolution_meters": resolution,
|
||||
"boundary_inset_meters": float(self.config.get("boundary_inset_meters", 1.0)),
|
||||
}
|
||||
|
||||
def grid(self, resolution: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
resolution = max(float(resolution), 0.05)
|
||||
cached = self._grids.get(resolution)
|
||||
if cached is not None:
|
||||
return cached
|
||||
x_min, x_max = self.bounds[0]
|
||||
y_min, y_max = self.bounds[1]
|
||||
cols = max(2, int(math.ceil((x_max - x_min) / resolution)) + 1)
|
||||
rows = max(2, int(math.ceil((y_max - y_min) / resolution)) + 1)
|
||||
grid = np.full((rows, cols), np.inf, dtype=np.float32)
|
||||
indices = self.indices()
|
||||
for start in range(0, len(indices), _BATCH_SIZE):
|
||||
points = np.asarray(self.xyz[indices[start : start + _BATCH_SIZE]], dtype=np.float32)
|
||||
gx = np.clip(((points[:, 0] - x_min) / resolution).astype(np.int32), 0, cols - 1)
|
||||
gy = np.clip(((points[:, 1] - y_min) / resolution).astype(np.int32), 0, rows - 1)
|
||||
np.minimum.at(grid, (gy, gx), points[:, 2])
|
||||
missing = ~np.isfinite(grid)
|
||||
if missing.all():
|
||||
raise ValueError("기준 필터에 지면 포인트가 없습니다.")
|
||||
if missing.any():
|
||||
nearest = ndimage.distance_transform_edt(
|
||||
missing, return_distances=False, return_indices=True
|
||||
)
|
||||
grid = grid[tuple(nearest)]
|
||||
x_coords = np.linspace(x_min, x_max, cols, dtype=np.float32)
|
||||
y_coords = np.linspace(y_min, y_max, rows, dtype=np.float32)
|
||||
result = (x_coords, y_coords, grid)
|
||||
self._grids[resolution] = result
|
||||
return result
|
||||
|
||||
def preview_grid(
|
||||
self, preferred_resolution: float
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
x_span = max(float(self.bounds[0, 1] - self.bounds[0, 0]), preferred_resolution)
|
||||
y_span = max(float(self.bounds[1, 1] - self.bounds[1, 0]), preferred_resolution)
|
||||
maximum = max(4, int(self.config["max_preview_vertices"]))
|
||||
predicted = (x_span / preferred_resolution + 1) * (y_span / preferred_resolution + 1)
|
||||
if predicted > maximum:
|
||||
preferred_resolution *= math.sqrt(predicted / maximum)
|
||||
return self.grid(preferred_resolution)
|
||||
|
||||
def clear_caches(self) -> None:
|
||||
self._samples.clear()
|
||||
self._grids.clear()
|
||||
self._indices = None
|
||||
|
||||
|
||||
def with_footprint(context: TerrainContext, metadata: dict[str, Any]) -> dict[str, Any]:
|
||||
metadata.update(context.footprint_metadata())
|
||||
return metadata
|
||||
|
||||
|
||||
# 모델 빌더가 사용하는 원자적 저장 래퍼 (공통 유틸 재노출)
|
||||
atomic_npz = atomic_write_npz
|
||||
@@ -0,0 +1,328 @@
|
||||
"""B04 지표면 모델 파이프라인 오케스트레이터.
|
||||
|
||||
세 지면 필터(grid_min_z/csf/pmf)와 다섯 표현(TIN/DTM/NURBS/implicit/meshfree)의
|
||||
캐시를 만들고 manifest.json을 관리한다. 캐시 유효성 검증, 스무딩/등고선 연동,
|
||||
동일 출력 폴더의 중복 실행 취소를 포함한다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Contour import (
|
||||
CONTOUR_EXTRACTOR_VERSION,
|
||||
extract_contours,
|
||||
)
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_ModelBuild import BUILDERS
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_ModelContext import (
|
||||
MODEL_VERSION,
|
||||
TerrainContext,
|
||||
bounds_dict,
|
||||
config_signature,
|
||||
)
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Smooth import (
|
||||
compute_smoothing_signature,
|
||||
run_smoothing,
|
||||
)
|
||||
from common_util.common_util_atomic import atomic_write_bytes
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
|
||||
# 진행률 콜백: (overall_percent, detail_message)
|
||||
ProgressReporter = Callable[[int, str], None]
|
||||
|
||||
# 같은 프로세스에서 동일 프로젝트 계산 요청이 겹치면 두 번째 요청을 즉시 취소.
|
||||
_ACTIVE_TERRAIN_BUILDS: set[str] = set()
|
||||
_ACTIVE_TERRAIN_BUILDS_GUARD = threading.Lock()
|
||||
|
||||
|
||||
def _write_json_file(path: Path, value: dict[str, Any]) -> None:
|
||||
atomic_write_json(path, value)
|
||||
|
||||
|
||||
def _cache_contours(
|
||||
output_dir: Path,
|
||||
stem: str,
|
||||
filter_key: str,
|
||||
method: str,
|
||||
representation: str,
|
||||
config: dict[str, Any],
|
||||
bounds_info: dict[str, Any],
|
||||
metadata: dict[str, Any],
|
||||
) -> None:
|
||||
"""빌드 완료 직후 기본 간격 등고선을 사전 추출·캐싱한다 (원본 + 스무딩)."""
|
||||
interval = float(config.get("contour_interval_meters", 5.0))
|
||||
target_grid_m = float(config.get("contour_grid_resolution_meters", 1.0))
|
||||
model_path = output_dir / f"{stem}.npz"
|
||||
|
||||
if model_path.exists():
|
||||
contours = extract_contours(
|
||||
model_path,
|
||||
representation=representation,
|
||||
interval=interval,
|
||||
target_grid_m=target_grid_m,
|
||||
scene_center=None,
|
||||
)
|
||||
payload = {
|
||||
"extractor_version": CONTOUR_EXTRACTOR_VERSION,
|
||||
"project_id": output_dir.parent.name,
|
||||
"source_filter": filter_key,
|
||||
"method": method,
|
||||
"interval": interval,
|
||||
"bounds": bounds_info,
|
||||
"contours": contours,
|
||||
}
|
||||
atomic_write_bytes(
|
||||
output_dir / f"contour_{filter_key}_{method}_{interval}m.json",
|
||||
json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
||||
)
|
||||
|
||||
smooth_model_path = output_dir / f"{stem}_smooth.npz"
|
||||
smooth_meta = metadata.get("smooth", {})
|
||||
if smooth_model_path.exists() and smooth_meta.get("status") == "completed":
|
||||
smooth_rep = "regular_grid" if method == "dtm" else "triangular_mesh"
|
||||
smooth_contours = extract_contours(
|
||||
smooth_model_path,
|
||||
representation=smooth_rep,
|
||||
interval=interval,
|
||||
target_grid_m=target_grid_m,
|
||||
scene_center=None,
|
||||
)
|
||||
payload = {
|
||||
"extractor_version": CONTOUR_EXTRACTOR_VERSION,
|
||||
"project_id": output_dir.parent.name,
|
||||
"source_filter": filter_key,
|
||||
"method": method,
|
||||
"interval": interval,
|
||||
"bounds": bounds_info,
|
||||
"contours": smooth_contours,
|
||||
}
|
||||
atomic_write_bytes(
|
||||
output_dir / f"contour_{filter_key}_{method}_smooth_{interval}m.json",
|
||||
json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
||||
)
|
||||
|
||||
|
||||
_REPRESENTATIONS = {
|
||||
"meshfree": "meshfree_surfels",
|
||||
"dtm": "regular_grid",
|
||||
"tin": "triangular_mesh",
|
||||
"nurbs": "bspline_surface",
|
||||
"implicit": "local_rbf_height_field",
|
||||
}
|
||||
|
||||
|
||||
def _cache_is_valid(
|
||||
output_dir: Path, stem: str, method: str, entry: dict[str, Any], config: dict[str, Any]
|
||||
) -> bool:
|
||||
"""디스크의 결과 파일과 스무딩 메타데이터가 유효한지 검사한다."""
|
||||
ext = "ply" if method == "meshfree" else "glb"
|
||||
files_exist = (output_dir / f"{stem}_preview.{ext}").exists() and (
|
||||
output_dir / f"{stem}.npz"
|
||||
).exists()
|
||||
if not files_exist:
|
||||
return False
|
||||
if method in config.get("smoothing_methods", ("dtm", "tin")):
|
||||
smooth_entry = entry.get("smooth", {})
|
||||
smooth_exist = (output_dir / f"{stem}_smooth.npz").exists() and (
|
||||
output_dir / f"{stem}_smooth_preview.glb"
|
||||
).exists()
|
||||
if (
|
||||
not smooth_exist
|
||||
or smooth_entry.get("status") != "completed"
|
||||
or smooth_entry.get("smoothing_signature") != compute_smoothing_signature(config)
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _build_all_terrain_models(
|
||||
structured_data: dict[str, np.ndarray] | np.lib.npyio.NpzFile,
|
||||
ground_masks: dict[str, np.ndarray],
|
||||
output_dir: Path,
|
||||
config: dict[str, Any],
|
||||
*,
|
||||
force: bool = False,
|
||||
progress: ProgressReporter | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""세 지면 필터와 다섯 표현의 캐시를 만들고 manifest를 반환한다."""
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
manifest_path = output_dir / "manifest.json"
|
||||
filters = tuple(key for key in config["source_filters"] if key in ground_masks)
|
||||
methods = tuple(key for key in config["precompute"] if key in BUILDERS)
|
||||
signature = config_signature(config)
|
||||
bounds = np.asarray(structured_data["bounds"], dtype=np.float64)
|
||||
xyz = structured_data["xyz"]
|
||||
|
||||
existing: dict[str, Any] = {}
|
||||
if manifest_path.exists() and not force:
|
||||
try:
|
||||
existing = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
existing = {}
|
||||
if existing.get("config_signature") != signature:
|
||||
existing = {}
|
||||
manifest: dict[str, Any] = existing or {
|
||||
"version": MODEL_VERSION,
|
||||
"config_signature": signature,
|
||||
"bounds": bounds_dict(bounds),
|
||||
"source_filters": {},
|
||||
"started_at_unix": time.time(),
|
||||
}
|
||||
started_at = time.monotonic()
|
||||
timeout = max(0, int(config.get("sync_timeout_seconds", 0)))
|
||||
total_units = max(1, len(filters) * len(methods))
|
||||
done_units = 0
|
||||
failures = 0
|
||||
|
||||
def _report(detail: str) -> None:
|
||||
if progress:
|
||||
progress(int(100 * done_units / total_units), detail)
|
||||
|
||||
for filter_index, filter_key in enumerate(filters):
|
||||
mask = np.asarray(ground_masks[filter_key], dtype=bool)
|
||||
if len(mask) != len(xyz):
|
||||
raise ValueError(f"{filter_key} 마스크 길이가 XYZ 데이터와 다릅니다.")
|
||||
context = TerrainContext(xyz=xyz, mask=mask, bounds=bounds, config=config)
|
||||
filter_entry = manifest["source_filters"].setdefault(
|
||||
filter_key, {"source_point_count": context.source_count, "methods": {}}
|
||||
)
|
||||
filter_entry["source_point_count"] = context.source_count
|
||||
|
||||
for method in methods:
|
||||
stem = f"{method}_{filter_key}"
|
||||
entry = filter_entry["methods"].get(method, {})
|
||||
|
||||
if not force and _cache_is_valid(output_dir, stem, method, entry, config):
|
||||
if entry.get("status") != "completed":
|
||||
entry.update(
|
||||
{
|
||||
"status": "completed",
|
||||
"representation": _REPRESENTATIONS[method],
|
||||
"model_file": f"{stem}.npz",
|
||||
"preview_file": f"{stem}_preview."
|
||||
+ ("ply" if method == "meshfree" else "glb"),
|
||||
"preview_media_type": "application/octet-stream"
|
||||
if method == "meshfree"
|
||||
else "model/gltf-binary",
|
||||
"error": None,
|
||||
}
|
||||
)
|
||||
filter_entry["methods"][method] = entry
|
||||
_write_json_file(manifest_path, manifest)
|
||||
done_units += 1
|
||||
_report(f"{filter_key}-{method} 캐시 재사용")
|
||||
continue
|
||||
|
||||
if timeout and time.monotonic() - started_at >= timeout:
|
||||
failures += 1
|
||||
filter_entry["methods"][method] = {
|
||||
"status": "failed",
|
||||
"error": f"동기 계산 제한시간 {timeout}초를 초과했습니다.",
|
||||
}
|
||||
_write_json_file(manifest_path, manifest)
|
||||
done_units += 1
|
||||
_report(f"{filter_key}-{method} 시간 초과")
|
||||
continue
|
||||
|
||||
method_started = time.monotonic()
|
||||
filter_entry["methods"][method] = {"status": "running", "error": None}
|
||||
_write_json_file(manifest_path, manifest)
|
||||
try:
|
||||
metadata = BUILDERS[method](
|
||||
context,
|
||||
output_dir,
|
||||
stem,
|
||||
lambda value: _report(f"{filter_key}-{method} {value}%"),
|
||||
)
|
||||
metadata.update(
|
||||
{
|
||||
"status": "completed",
|
||||
"duration_seconds": round(time.monotonic() - method_started, 3),
|
||||
"error": None,
|
||||
}
|
||||
)
|
||||
if method in config.get("smoothing_methods", ("dtm", "tin")):
|
||||
original_model_path = output_dir / f"{stem}.npz"
|
||||
if original_model_path.exists():
|
||||
try:
|
||||
smooth_meta = run_smoothing(
|
||||
method, context, output_dir, stem, original_model_path
|
||||
)
|
||||
smooth_meta["status"] = "completed"
|
||||
metadata["smooth"] = smooth_meta
|
||||
except Exception as smooth_exc:
|
||||
metadata["smooth"] = {"status": "failed", "error": str(smooth_exc)}
|
||||
|
||||
filter_entry["methods"][method] = metadata
|
||||
try:
|
||||
_cache_contours(
|
||||
output_dir,
|
||||
stem,
|
||||
filter_key,
|
||||
method,
|
||||
metadata.get("representation", "regular_grid"),
|
||||
config,
|
||||
manifest.get("bounds", {}),
|
||||
metadata,
|
||||
)
|
||||
except Exception:
|
||||
pass # 등고선 사전 캐시는 실패해도 모델 빌드를 무효화하지 않는다.
|
||||
except Exception as exc:
|
||||
failures += 1
|
||||
filter_entry["methods"][method] = {
|
||||
"status": "failed",
|
||||
"duration_seconds": round(time.monotonic() - method_started, 3),
|
||||
"error": str(exc),
|
||||
}
|
||||
done_units += 1
|
||||
_report(f"{filter_key}-{method} 완료")
|
||||
_write_json_file(manifest_path, manifest)
|
||||
|
||||
context.clear_caches()
|
||||
|
||||
manifest["status"] = "completed" if failures == 0 else "completed_with_errors"
|
||||
manifest["completed_at_unix"] = time.time()
|
||||
manifest["duration_seconds"] = round(time.monotonic() - started_at, 3)
|
||||
manifest["failure_count"] = failures
|
||||
_write_json_file(manifest_path, manifest)
|
||||
return manifest
|
||||
|
||||
|
||||
def build_all_terrain_models(
|
||||
structured_data: dict[str, np.ndarray] | np.lib.npyio.NpzFile,
|
||||
ground_masks: dict[str, np.ndarray],
|
||||
output_dir: Path,
|
||||
config: dict[str, Any],
|
||||
*,
|
||||
force: bool = False,
|
||||
progress: ProgressReporter | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""동일 출력 폴더의 중복 실행을 즉시 취소하고 실제 빌드를 한 번만 수행한다."""
|
||||
output_dir = Path(output_dir)
|
||||
build_key = str(output_dir.resolve())
|
||||
with _ACTIVE_TERRAIN_BUILDS_GUARD:
|
||||
if build_key in _ACTIVE_TERRAIN_BUILDS:
|
||||
manifest_path = output_dir / "manifest.json"
|
||||
try:
|
||||
current = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
current = {"status": "running", "source_filters": {}}
|
||||
response = dict(current)
|
||||
response["request_status"] = "cancelled_already_running"
|
||||
response["message"] = (
|
||||
"동일 프로젝트의 지표면 모델 계산이 이미 진행 중이어서 요청을 취소했습니다."
|
||||
)
|
||||
return response
|
||||
_ACTIVE_TERRAIN_BUILDS.add(build_key)
|
||||
|
||||
try:
|
||||
return _build_all_terrain_models(
|
||||
structured_data, ground_masks, output_dir, config, force=force, progress=progress
|
||||
)
|
||||
finally:
|
||||
with _ACTIVE_TERRAIN_BUILDS_GUARD:
|
||||
_ACTIVE_TERRAIN_BUILDS.discard(build_key)
|
||||
@@ -0,0 +1,151 @@
|
||||
"""B04 지표면 스무딩 엔진 (DTM 가우시안+B-Spline, TIN Taubin).
|
||||
|
||||
원본 모델(npz)을 로드해 표고 Z만 평활화한 스무딩 모델과 프리뷰(GLB)를 만든다.
|
||||
XY 위치와 삼각형 연결은 원본을 유지한다.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import trimesh
|
||||
from scipy import ndimage
|
||||
from scipy.interpolate import RectBivariateSpline
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_ModelContext import (
|
||||
TerrainContext,
|
||||
artifact_size,
|
||||
atomic_npz,
|
||||
clip_and_compact_mesh,
|
||||
grid_faces,
|
||||
grid_vertices,
|
||||
write_glb,
|
||||
)
|
||||
|
||||
SMOOTHING_ALGORITHM_VERSION = 2
|
||||
|
||||
|
||||
def compute_smoothing_signature(config: dict[str, Any]) -> str:
|
||||
"""스무딩 관련 파라미터의 해시 서명을 계산한다."""
|
||||
smooth_params = {k: v for k, v in config.items() if k.startswith("smoothing_")}
|
||||
smooth_params["algorithm_version"] = SMOOTHING_ALGORITHM_VERSION
|
||||
encoded = json.dumps(smooth_params, sort_keys=True, default=list).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()[:16]
|
||||
|
||||
|
||||
def _masked_gaussian_filter(grid: np.ndarray, mask: np.ndarray, sigma: float) -> np.ndarray:
|
||||
"""무효 영역(mask==False) 오염을 방지하는 정규화 가우시안 필터."""
|
||||
if sigma <= 0:
|
||||
return grid.copy()
|
||||
values = grid.copy()
|
||||
values[~mask] = 0.0
|
||||
weights = np.zeros_like(grid, dtype=float)
|
||||
weights[mask] = 1.0
|
||||
value_blur = ndimage.gaussian_filter(values, sigma=sigma, mode="constant", cval=0.0)
|
||||
weight_blur = ndimage.gaussian_filter(weights, sigma=sigma, mode="constant", cval=0.0)
|
||||
valid_denom = weight_blur > 1e-10
|
||||
result = grid.copy()
|
||||
result[valid_denom] = value_blur[valid_denom] / weight_blur[valid_denom]
|
||||
return result
|
||||
|
||||
|
||||
def smooth_dtm(
|
||||
context: TerrainContext, output_dir: Path, stem: str, original_model_path: Path
|
||||
) -> dict[str, Any]:
|
||||
"""DTM 격자에 가우시안+C2 바이큐빅 보간을 적용해 스무딩 모델을 만든다."""
|
||||
data = np.load(original_model_path)
|
||||
x_orig, y_orig, z_orig, valid_orig = data["x"], data["y"], data["z"], data["valid_mask"]
|
||||
|
||||
resolution = float(context.config["dtm_grid_resolution_meters"])
|
||||
sigma_meters = float(context.config.get("smoothing_dtm_sigma_meters", 0.5))
|
||||
sigma_pixels = sigma_meters / resolution if resolution > 0 else 0.0
|
||||
z_pre = _masked_gaussian_filter(z_orig, valid_orig, sigma_pixels)
|
||||
|
||||
spline = RectBivariateSpline(
|
||||
y_orig,
|
||||
x_orig,
|
||||
z_pre,
|
||||
kx=3,
|
||||
ky=3,
|
||||
s=float(context.config.get("smoothing_dtm_spline_smooth", 0.0)),
|
||||
)
|
||||
preview_res = float(context.config.get("smoothing_dtm_preview_resolution_meters", 0.5))
|
||||
x_coords, y_coords, _ = context.preview_grid(preview_res)
|
||||
z_smooth = np.asarray(spline(y_coords, x_coords), dtype=np.float32)
|
||||
valid_grid = context.contains_xy(*np.meshgrid(x_coords, y_coords)).reshape(
|
||||
len(y_coords), len(x_coords)
|
||||
)
|
||||
vertices = grid_vertices(x_coords, y_coords, z_smooth)
|
||||
faces = grid_faces(len(y_coords), len(x_coords))
|
||||
preview_valid = context.contains_xy(vertices[:, 0], vertices[:, 1])
|
||||
vertices_compact, faces_compact = clip_and_compact_mesh(vertices, faces, preview_valid)
|
||||
|
||||
model_path = output_dir / f"{stem}_smooth.npz"
|
||||
preview_path = output_dir / f"{stem}_smooth_preview.glb"
|
||||
atomic_npz(
|
||||
model_path,
|
||||
x=x_coords,
|
||||
y=y_coords,
|
||||
z=z_smooth,
|
||||
valid_mask=valid_grid,
|
||||
resolution=np.array([preview_res], np.float32),
|
||||
)
|
||||
write_glb(preview_path, vertices_compact, faces_compact, context.bounds)
|
||||
return {
|
||||
"model_file": model_path.name,
|
||||
"preview_file": preview_path.name,
|
||||
"preview_media_type": "model/gltf-binary",
|
||||
"vertex_count": int(len(vertices_compact)),
|
||||
"face_count": int(len(faces_compact)),
|
||||
"artifact_bytes": artifact_size(model_path, preview_path),
|
||||
"smoothing_signature": compute_smoothing_signature(context.config),
|
||||
}
|
||||
|
||||
|
||||
def smooth_tin(
|
||||
context: TerrainContext, output_dir: Path, stem: str, original_model_path: Path
|
||||
) -> dict[str, Any]:
|
||||
"""TIN 삼각망에 Taubin 저수축 스무딩을 적용한다 (Z만 평활화)."""
|
||||
data = np.load(original_model_path)
|
||||
vertices, faces = data["vertices"], data["faces"]
|
||||
if len(vertices) < 3 or not len(faces):
|
||||
raise ValueError("TIN 스무딩을 수행할 삼각망 메쉬 데이터가 올바르지 않습니다.")
|
||||
|
||||
iterations = int(context.config.get("smoothing_tin_taubin_iterations", 10))
|
||||
lamb = float(context.config.get("smoothing_tin_taubin_lambda", 0.5))
|
||||
mu = float(context.config.get("smoothing_tin_taubin_mu", -0.53))
|
||||
mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=False)
|
||||
if iterations > 0:
|
||||
trimesh.smoothing.filter_taubin(mesh, lamb=lamb, nu=mu, iterations=iterations)
|
||||
|
||||
vertices_smooth = np.asarray(mesh.vertices, dtype=np.float32)
|
||||
# 지표면 스무딩은 리토폴로지가 아니다: XY·연결은 원본, Z만 평활화.
|
||||
vertices_smooth[:, :2] = np.asarray(vertices[:, :2], dtype=np.float32)
|
||||
faces_smooth = np.asarray(mesh.faces, dtype=np.uint32)
|
||||
|
||||
model_path = output_dir / f"{stem}_smooth.npz"
|
||||
preview_path = output_dir / f"{stem}_smooth_preview.glb"
|
||||
atomic_npz(model_path, vertices=vertices_smooth, faces=faces_smooth)
|
||||
write_glb(preview_path, vertices_smooth, faces_smooth, context.bounds)
|
||||
return {
|
||||
"model_file": model_path.name,
|
||||
"preview_file": preview_path.name,
|
||||
"preview_media_type": "model/gltf-binary",
|
||||
"vertex_count": int(len(vertices_smooth)),
|
||||
"face_count": int(len(faces_smooth)),
|
||||
"artifact_bytes": artifact_size(model_path, preview_path),
|
||||
"smoothing_signature": compute_smoothing_signature(context.config),
|
||||
}
|
||||
|
||||
|
||||
def run_smoothing(
|
||||
method: str, context: TerrainContext, output_dir: Path, stem: str, original_model_path: Path
|
||||
) -> dict[str, Any]:
|
||||
"""방식에 따라 적절한 스무딩 엔진을 수행한다."""
|
||||
if method == "dtm":
|
||||
return smooth_dtm(context, output_dir, stem, original_model_path)
|
||||
if method == "tin":
|
||||
return smooth_tin(context, output_dir, stem, original_model_path)
|
||||
raise ValueError(f"스무딩이 불가능한 지형 표현 방식입니다: {method}")
|
||||
@@ -0,0 +1,112 @@
|
||||
"""B04 LAS/LAZ 고속 구조화 엔진."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import laspy
|
||||
import numpy as np
|
||||
|
||||
from config.config_system import SURFACE_DEFAULT_RGB_VALUE, SURFACE_LAS_CHUNK_SIZE
|
||||
|
||||
|
||||
def structurize_las(
|
||||
las_path: str | Path,
|
||||
output_dir: str | Path,
|
||||
progress_callback: Callable[[int], None] | None = None,
|
||||
) -> Path:
|
||||
"""LAS/LAZ 속성을 청크로 읽어 B04 structured.npz로 원자적 저장한다."""
|
||||
source = Path(las_path)
|
||||
target_dir = Path(output_dir)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = target_dir / "structured.npz"
|
||||
|
||||
with laspy.open(source) as las_file:
|
||||
header = las_file.header
|
||||
total_points = int(header.point_count)
|
||||
point_format = header.point_format
|
||||
dimensions = set(point_format.dimension_names)
|
||||
has_rgb = {"red", "green", "blue"}.issubset(dimensions)
|
||||
has_intensity = "intensity" in dimensions
|
||||
has_returns = {"return_number", "number_of_returns"}.issubset(dimensions)
|
||||
has_classification = "classification" in dimensions
|
||||
bounds = np.array(
|
||||
[
|
||||
[float(header.mins[0]), float(header.maxs[0])],
|
||||
[float(header.mins[1]), float(header.maxs[1])],
|
||||
[float(header.mins[2]), float(header.maxs[2])],
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
||||
xyz = np.empty((total_points, 3), dtype=np.float64)
|
||||
intensity = np.zeros(total_points, dtype=np.uint16)
|
||||
rgb = np.full((total_points, 3), SURFACE_DEFAULT_RGB_VALUE, dtype=np.uint8)
|
||||
return_number = np.ones(total_points, dtype=np.uint8)
|
||||
number_of_returns = np.ones(total_points, dtype=np.uint8)
|
||||
classification = np.zeros(total_points, dtype=np.uint8)
|
||||
|
||||
offset = 0
|
||||
for chunk in las_file.chunk_iterator(SURFACE_LAS_CHUNK_SIZE):
|
||||
chunk_size = len(chunk)
|
||||
section = slice(offset, offset + chunk_size)
|
||||
xyz[section, 0] = np.asarray(chunk.x, dtype=np.float64)
|
||||
xyz[section, 1] = np.asarray(chunk.y, dtype=np.float64)
|
||||
xyz[section, 2] = np.asarray(chunk.z, dtype=np.float64)
|
||||
if has_intensity:
|
||||
intensity[section] = np.asarray(chunk.intensity, dtype=np.uint16)
|
||||
if has_rgb:
|
||||
colors = np.stack(
|
||||
[
|
||||
np.asarray(chunk.red, dtype=np.float64),
|
||||
np.asarray(chunk.green, dtype=np.float64),
|
||||
np.asarray(chunk.blue, dtype=np.float64),
|
||||
],
|
||||
axis=1,
|
||||
)
|
||||
if colors.size and float(colors.max()) > 255.0:
|
||||
colors /= 256.0
|
||||
rgb[section] = colors.clip(0, 255).astype(np.uint8)
|
||||
if has_returns:
|
||||
return_number[section] = np.asarray(chunk.return_number, dtype=np.uint8)
|
||||
number_of_returns[section] = np.asarray(chunk.number_of_returns, dtype=np.uint8)
|
||||
if has_classification:
|
||||
classification[section] = np.asarray(chunk.classification, dtype=np.uint8)
|
||||
offset += chunk_size
|
||||
if progress_callback:
|
||||
progress_callback(int(offset / total_points * 100) if total_points else 100)
|
||||
|
||||
temporary_path: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="wb",
|
||||
dir=target_dir,
|
||||
prefix=".structured.",
|
||||
suffix=".npz.tmp",
|
||||
delete=False,
|
||||
) as temporary:
|
||||
temporary_path = Path(temporary.name)
|
||||
np.savez_compressed(
|
||||
temporary,
|
||||
xyz=xyz,
|
||||
intensity=intensity,
|
||||
rgb=rgb,
|
||||
return_number=return_number,
|
||||
number_of_returns=number_of_returns,
|
||||
classification=classification,
|
||||
bounds=bounds,
|
||||
total_points=np.array([total_points], dtype=np.int64),
|
||||
has_rgb=np.array([int(has_rgb)], dtype=np.int8),
|
||||
)
|
||||
temporary.flush()
|
||||
os.fsync(temporary.fileno())
|
||||
os.replace(temporary_path, target)
|
||||
temporary_path = None
|
||||
finally:
|
||||
if temporary_path is not None:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
|
||||
if progress_callback and total_points == 0:
|
||||
progress_callback(100)
|
||||
return target
|
||||
@@ -0,0 +1,227 @@
|
||||
"""B04 지표면 분석 결과의 aiomysql Raw SQL 접근.
|
||||
|
||||
processed_point_cloud(변환 포인트클라우드), surface_models(지표면 모델),
|
||||
terrain_layers(지형 레이어) 테이블에 메타데이터와 상대 경로를 기록한다.
|
||||
공간 데이터는 MariaDB JSON 컬럼에 GeoJSON 문자열로 저장한다.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import aiomysql
|
||||
|
||||
_STAGE_ROOT = "B04_wf1_Surface"
|
||||
|
||||
|
||||
def _validate_stage_path(relative_path: str) -> str:
|
||||
"""B04_wf1_Surface 아래의 안전한 상대 경로인지 검증하고 posix 문자열로 반환한다."""
|
||||
normalized = PurePosixPath(relative_path.replace("\\", "/"))
|
||||
if normalized.is_absolute() or ".." in normalized.parts:
|
||||
raise ValueError("DB에는 프로젝트 루트 기준 상대 경로만 저장할 수 있습니다.")
|
||||
if not normalized.parts or normalized.parts[0] != _STAGE_ROOT:
|
||||
raise ValueError(f"B04 산출물 경로는 {_STAGE_ROOT} 아래여야 합니다.")
|
||||
return normalized.as_posix()
|
||||
|
||||
|
||||
async def create_processed_point_cloud(
|
||||
connection: aiomysql.Connection,
|
||||
*,
|
||||
input_file_id: int,
|
||||
project_id: UUID,
|
||||
process_type: str,
|
||||
processed_file_path: str | None,
|
||||
converted_format: str | None,
|
||||
converted_file_path: str | None,
|
||||
point_count: int | None,
|
||||
bounds: dict[str, Any] | None,
|
||||
statistics: dict[str, Any] | None,
|
||||
classification_summary: dict[str, Any] | None,
|
||||
processing_params: dict[str, Any] | None,
|
||||
status: str = "COMPLETE",
|
||||
) -> int:
|
||||
"""변환 포인트클라우드 메타데이터를 저장하고 생성된 ID를 반환한다."""
|
||||
processed_rel = _validate_stage_path(processed_file_path) if processed_file_path else None
|
||||
converted_rel = _validate_stage_path(converted_file_path) if converted_file_path else None
|
||||
stats = statistics or {}
|
||||
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
INSERT INTO processed_point_cloud (
|
||||
input_file_id, project_id, process_type,
|
||||
processed_file_path, converted_format, converted_file_path,
|
||||
point_count, min_z, max_z, mean_z,
|
||||
x_min, x_max, y_min, y_max, density_per_sqm,
|
||||
classification_summary, processing_params, status
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
input_file_id,
|
||||
str(project_id),
|
||||
process_type,
|
||||
processed_rel,
|
||||
converted_format,
|
||||
converted_rel,
|
||||
point_count,
|
||||
stats.get("min_z"),
|
||||
stats.get("max_z"),
|
||||
stats.get("mean_z"),
|
||||
(bounds or {}).get("x_min"),
|
||||
(bounds or {}).get("x_max"),
|
||||
(bounds or {}).get("y_min"),
|
||||
(bounds or {}).get("y_max"),
|
||||
stats.get("density_per_sqm"),
|
||||
json.dumps(classification_summary, ensure_ascii=False)
|
||||
if classification_summary is not None
|
||||
else None,
|
||||
json.dumps(processing_params, ensure_ascii=False)
|
||||
if processing_params is not None
|
||||
else None,
|
||||
status,
|
||||
),
|
||||
)
|
||||
new_id = cursor.lastrowid
|
||||
if not new_id:
|
||||
raise RuntimeError("processed_point_cloud 레코드 생성 결과에 ID가 없습니다.")
|
||||
return int(new_id)
|
||||
|
||||
|
||||
async def create_surface_model(
|
||||
connection: aiomysql.Connection,
|
||||
*,
|
||||
project_id: UUID,
|
||||
model_type: str,
|
||||
source_file_id: int | None,
|
||||
processed_cloud_id: int | None,
|
||||
crs_epsg: int | None,
|
||||
resolution_m: float | None,
|
||||
model_file_path: str | None,
|
||||
generation_params: dict[str, Any] | None,
|
||||
status: str = "COMPLETE",
|
||||
) -> int:
|
||||
"""지표면 모델 메타데이터를 저장하고 생성된 ID를 반환한다."""
|
||||
model_rel = _validate_stage_path(model_file_path) if model_file_path else None
|
||||
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
INSERT INTO surface_models (
|
||||
project_id, model_type, source_file_id, processed_cloud_id,
|
||||
status, crs_epsg, resolution_m, model_file_path,
|
||||
generation_params, completed_at
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
|
||||
""",
|
||||
(
|
||||
str(project_id),
|
||||
model_type,
|
||||
source_file_id,
|
||||
processed_cloud_id,
|
||||
status,
|
||||
crs_epsg,
|
||||
resolution_m,
|
||||
model_rel,
|
||||
json.dumps(generation_params, ensure_ascii=False)
|
||||
if generation_params is not None
|
||||
else None,
|
||||
),
|
||||
)
|
||||
new_id = cursor.lastrowid
|
||||
if not new_id:
|
||||
raise RuntimeError("surface_models 레코드 생성 결과에 ID가 없습니다.")
|
||||
return int(new_id)
|
||||
|
||||
|
||||
async def create_terrain_layer(
|
||||
connection: aiomysql.Connection,
|
||||
*,
|
||||
surface_model_id: int,
|
||||
layer_name: str,
|
||||
geometry_type: str,
|
||||
layer_file_path: str | None,
|
||||
file_format: str | None,
|
||||
file_size_mb: float | None,
|
||||
statistics: dict[str, Any] | None,
|
||||
) -> int:
|
||||
"""지형 레이어 메타데이터를 저장하고 생성된 ID를 반환한다."""
|
||||
layer_rel = _validate_stage_path(layer_file_path) if layer_file_path else None
|
||||
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
INSERT INTO terrain_layers (
|
||||
surface_model_id, layer_name, geometry_type,
|
||||
layer_file_path, file_format, file_size_mb, statistics
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
surface_model_id,
|
||||
layer_name,
|
||||
geometry_type,
|
||||
layer_rel,
|
||||
file_format,
|
||||
file_size_mb,
|
||||
json.dumps(statistics, ensure_ascii=False) if statistics is not None else None,
|
||||
),
|
||||
)
|
||||
new_id = cursor.lastrowid
|
||||
if not new_id:
|
||||
raise RuntimeError("terrain_layers 레코드 생성 결과에 ID가 없습니다.")
|
||||
return int(new_id)
|
||||
|
||||
|
||||
async def get_input_file(
|
||||
connection: aiomysql.Connection, project_id: UUID, input_file_id: int
|
||||
) -> dict[str, Any]:
|
||||
"""프로젝트의 특정 입력 파일 경로·좌표계를 조회한다."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT id, file_type, raw_file_path, crs_epsg
|
||||
FROM input_files
|
||||
WHERE id = %s AND project_id = %s
|
||||
""",
|
||||
(input_file_id, str(project_id)),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
raise LookupError("입력 파일을 찾을 수 없습니다.")
|
||||
return {
|
||||
"id": int(row[0]),
|
||||
"file_type": row[1],
|
||||
"raw_file_path": row[2],
|
||||
"crs_epsg": row[3],
|
||||
}
|
||||
|
||||
|
||||
async def list_surface_models(
|
||||
connection: aiomysql.Connection, project_id: UUID
|
||||
) -> list[dict[str, Any]]:
|
||||
"""프로젝트의 지표면 모델 목록을 최신순으로 조회한다."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT id, model_type, status, resolution_m, model_file_path, created_at
|
||||
FROM surface_models
|
||||
WHERE project_id = %s
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(str(project_id),),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": int(row[0]),
|
||||
"model_type": row[1],
|
||||
"status": row[2],
|
||||
"resolution_m": row[3],
|
||||
"model_file_path": row[4],
|
||||
"created_at": row[5].isoformat() if row[5] else None,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
@@ -0,0 +1,149 @@
|
||||
"""B04 지표면 분석 FastAPI 라우터."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine import run_surface_analysis
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Repository import (
|
||||
create_processed_point_cloud,
|
||||
create_surface_model,
|
||||
create_terrain_layer,
|
||||
get_input_file,
|
||||
list_surface_models,
|
||||
)
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Schema import (
|
||||
SurfaceAnalyzeRequest,
|
||||
SurfaceAnalyzeResponse,
|
||||
SurfaceModelListResponse,
|
||||
SurfaceModelSummary,
|
||||
)
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Analysis"])
|
||||
|
||||
|
||||
@router.post("/{project_id}/surface/analyze", response_model=SurfaceAnalyzeResponse)
|
||||
async def analyze_surface(
|
||||
project_id: UUID, request: SurfaceAnalyzeRequest
|
||||
) -> SurfaceAnalyzeResponse | JSONResponse:
|
||||
"""LAS 구조화·지면 필터·지표면 모델 생성을 실행하고 DB에 기록한다."""
|
||||
try:
|
||||
source_filters = request.resolved_filters()
|
||||
methods = request.resolved_methods()
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
input_file = await get_input_file(connection, project_id, request.input_file_id)
|
||||
las_path = project_root / Path(input_file["raw_file_path"])
|
||||
if not las_path.is_file():
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "원본 LAS 파일을 찾을 수 없습니다."},
|
||||
)
|
||||
|
||||
# 무거운 지형 연산은 이벤트 루프를 막지 않도록 별도 스레드에서 실행.
|
||||
result = await asyncio.to_thread(
|
||||
run_surface_analysis,
|
||||
project_root,
|
||||
las_path,
|
||||
source_filters=source_filters,
|
||||
methods=methods,
|
||||
force=request.force,
|
||||
)
|
||||
|
||||
# DB 기록 (트랜잭션)
|
||||
await connection.begin()
|
||||
try:
|
||||
processed = result["processed"]
|
||||
processed_cloud_id = await create_processed_point_cloud(
|
||||
connection,
|
||||
input_file_id=request.input_file_id,
|
||||
project_id=project_id,
|
||||
process_type="structured",
|
||||
processed_file_path=processed["processed_file_path"],
|
||||
converted_format=None,
|
||||
converted_file_path=processed["converted_file_path"],
|
||||
point_count=processed["point_count"],
|
||||
bounds=processed["bounds"],
|
||||
statistics=processed["statistics"],
|
||||
classification_summary=None,
|
||||
processing_params={"filters": source_filters},
|
||||
)
|
||||
surface_model_ids: list[int] = []
|
||||
for model in result["models"]:
|
||||
model_id = await create_surface_model(
|
||||
connection,
|
||||
project_id=project_id,
|
||||
model_type=model["model_type"],
|
||||
source_file_id=request.input_file_id,
|
||||
processed_cloud_id=processed_cloud_id,
|
||||
crs_epsg=input_file["crs_epsg"],
|
||||
resolution_m=model["resolution_m"],
|
||||
model_file_path=model["model_file_path"],
|
||||
generation_params=model["generation_params"],
|
||||
)
|
||||
surface_model_ids.append(model_id)
|
||||
for layer in model["layers"]:
|
||||
await create_terrain_layer(
|
||||
connection,
|
||||
surface_model_id=model_id,
|
||||
layer_name=layer["layer_name"],
|
||||
geometry_type=layer["geometry_type"],
|
||||
layer_file_path=layer["file_path"],
|
||||
file_format=layer["file_format"],
|
||||
file_size_mb=None,
|
||||
statistics=None,
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
|
||||
return SurfaceAnalyzeResponse(
|
||||
project_id=str(project_id),
|
||||
ground_summary=result["ground_summary"],
|
||||
manifest_status=result["manifest"].get("status", "unknown"),
|
||||
surface_model_ids=surface_model_ids,
|
||||
)
|
||||
except LookupError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except (OSError, ValueError) as exc:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception("B04 지표면 분석 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "지표면 분석 처리 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/surface/models", response_model=SurfaceModelListResponse)
|
||||
async def get_surface_models(project_id: UUID) -> SurfaceModelListResponse | JSONResponse:
|
||||
"""프로젝트의 지표면 모델 목록을 조회한다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
models = await list_surface_models(connection, project_id)
|
||||
return SurfaceModelListResponse(
|
||||
project_id=str(project_id),
|
||||
models=[SurfaceModelSummary(**model) for model in models],
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("B04 지표면 모델 목록 조회 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "모델 목록 조회 중 오류가 발생했습니다."},
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""B04 지표면 분석 요청·응답 검증 모델."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from config.config_system import (
|
||||
SURFACE_MODEL_PRECOMPUTE,
|
||||
SURFACE_MODEL_SOURCE_FILTERS,
|
||||
)
|
||||
|
||||
_ALLOWED_FILTERS = set(SURFACE_MODEL_SOURCE_FILTERS) | {"ransac"}
|
||||
_ALLOWED_METHODS = set(SURFACE_MODEL_PRECOMPUTE)
|
||||
|
||||
|
||||
class SurfaceAnalyzeRequest(BaseModel):
|
||||
"""지표면 분석 실행 요청."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
input_file_id: int = Field(gt=0, description="구조화 대상 원본 LAS input_files.id")
|
||||
source_filters: list[str] | None = Field(
|
||||
default=None, description="실행할 지면 필터 (미지정 시 config 기본값)"
|
||||
)
|
||||
methods: list[str] | None = Field(
|
||||
default=None, description="생성할 지표면 표현 (미지정 시 config 기본값)"
|
||||
)
|
||||
force: bool = Field(default=False, description="캐시 무시 후 강제 재계산")
|
||||
|
||||
def resolved_filters(self) -> list[str]:
|
||||
filters = self.source_filters or list(SURFACE_MODEL_SOURCE_FILTERS)
|
||||
invalid = [f for f in filters if f not in _ALLOWED_FILTERS]
|
||||
if invalid:
|
||||
raise ValueError(f"허용되지 않은 지면 필터입니다: {invalid}")
|
||||
return filters
|
||||
|
||||
def resolved_methods(self) -> list[str]:
|
||||
methods = self.methods or list(SURFACE_MODEL_PRECOMPUTE)
|
||||
invalid = [m for m in methods if m not in _ALLOWED_METHODS]
|
||||
if invalid:
|
||||
raise ValueError(f"허용되지 않은 지표면 표현입니다: {invalid}")
|
||||
return methods
|
||||
|
||||
|
||||
class SurfaceModelSummary(BaseModel):
|
||||
"""저장된 지표면 모델 요약."""
|
||||
|
||||
id: int
|
||||
model_type: str
|
||||
status: str
|
||||
resolution_m: float | None = None
|
||||
model_file_path: str | None = None
|
||||
created_at: str | None = None
|
||||
|
||||
|
||||
class SurfaceAnalyzeResponse(BaseModel):
|
||||
"""지표면 분석 실행 결과."""
|
||||
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
ground_summary: dict[str, Any]
|
||||
manifest_status: str
|
||||
surface_model_ids: list[int]
|
||||
|
||||
|
||||
class SurfaceModelListResponse(BaseModel):
|
||||
"""프로젝트 지표면 모델 목록 응답."""
|
||||
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
models: list[SurfaceModelSummary]
|
||||
@@ -2,27 +2,249 @@
|
||||
* B04_wf1_Surface_UI_Page.ts
|
||||
* 로그인 후 04: 1차 워크플로우 (지표면 모델 분석)
|
||||
*
|
||||
* ⚠️ 좌측 입력 패널 / 우측 WebCAD 뷰어 본문은 준비 중 — 워크플로우 셸(헤더+
|
||||
* 스텝바 = 3단 레이아웃)만 구성. 실제 본문은 0_old 참고하여 추후 구체화.
|
||||
* 3단 레이아웃 (frontend.md §2):
|
||||
* 상단: 페이지 타이틀 + 진행 단계 스텝바 (createWorkflowShell)
|
||||
* 좌측: 입력 파일 ID + 지면 필터/지표면 표현 선택 + 실행 옵션 폼
|
||||
* 우측: 생성된 지표면 모델 목록 그리드
|
||||
*
|
||||
* 제약 준수 (frontend.md §2 3단 레이아웃): createWorkflowShell 재사용.
|
||||
* 이벤트 핸들러 명명 (frontend.md §4): onB04_Surface_[기능]_[액션]
|
||||
* 텍스트는 ui_template_locale에 선(先) 등록 후 참조 (frontend.md §3).
|
||||
* ========================================================================== */
|
||||
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { renderPendingWorkflow, workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createTag,
|
||||
createWorkflowShell,
|
||||
hideLoadingOverlay,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import {
|
||||
analyzeSurface,
|
||||
listSurfaceModels,
|
||||
type SurfaceModelSummary,
|
||||
} from "./B04_wf1_Surface_Api_Fetch";
|
||||
import "./B04_wf1_Surface_UI_Style.css";
|
||||
|
||||
/** locale 헬퍼 */
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* 페이지 진입점
|
||||
* -------------------------------------------------------------------------- */
|
||||
/** 선택 가능한 지면 필터 (config_system.SURFACE_MODEL_SOURCE_FILTERS + ransac) */
|
||||
const SOURCE_FILTERS = ["grid_min_z", "csf", "pmf", "ransac"] as const;
|
||||
/** 선택 가능한 지표면 표현 (config_system.SURFACE_MODEL_PRECOMPUTE) */
|
||||
const MODEL_METHODS = ["tin", "dtm", "nurbs", "implicit", "meshfree"] as const;
|
||||
|
||||
/** 체크박스 그룹 하나 생성 (라벨 + 항목들). 선택 값 Set을 반환. */
|
||||
function buildCheckboxGroup(
|
||||
legend: string,
|
||||
values: readonly string[],
|
||||
defaults: readonly string[],
|
||||
): { root: HTMLElement; selected: Set<string> } {
|
||||
const selected = new Set<string>(defaults);
|
||||
const root = document.createElement("fieldset");
|
||||
root.className = "b04-surface__group";
|
||||
const legendEl = document.createElement("legend");
|
||||
legendEl.className = "b04-surface__group-legend";
|
||||
legendEl.textContent = legend;
|
||||
root.append(legendEl);
|
||||
|
||||
for (const value of values) {
|
||||
const item = document.createElement("label");
|
||||
item.className = "b04-surface__check";
|
||||
const box = document.createElement("input");
|
||||
box.type = "checkbox";
|
||||
box.value = value;
|
||||
box.checked = selected.has(value);
|
||||
box.addEventListener("change", () => {
|
||||
if (box.checked) selected.add(value);
|
||||
else selected.delete(value);
|
||||
});
|
||||
const text = document.createElement("span");
|
||||
text.textContent = value;
|
||||
item.append(box, text);
|
||||
root.append(item);
|
||||
}
|
||||
return { root, selected };
|
||||
}
|
||||
|
||||
export function renderB04Surface(root: HTMLElement): void {
|
||||
renderPendingWorkflow(root, {
|
||||
const shell = createWorkflowShell({
|
||||
title: L("B04_Surface_Title"),
|
||||
steps: workflowSteps(),
|
||||
activeStep: 0,
|
||||
});
|
||||
|
||||
/* ---- 좌측 입력 패널 ---- */
|
||||
const inputFileField = createInputField({
|
||||
label: L("B04_Surface_Field_InputId"),
|
||||
type: "number",
|
||||
min: 1,
|
||||
placeholder: L("B04_Surface_Field_InputId_Placeholder"),
|
||||
});
|
||||
|
||||
const filterGroup = buildCheckboxGroup(L("B04_Surface_Group_Filters"), SOURCE_FILTERS, [
|
||||
"grid_min_z",
|
||||
"csf",
|
||||
"pmf",
|
||||
]);
|
||||
const methodGroup = buildCheckboxGroup(L("B04_Surface_Group_Methods"), MODEL_METHODS, [
|
||||
"dtm",
|
||||
"tin",
|
||||
]);
|
||||
|
||||
const forceLabel = document.createElement("label");
|
||||
forceLabel.className = "b04-surface__check";
|
||||
const forceBox = document.createElement("input");
|
||||
forceBox.type = "checkbox";
|
||||
const forceText = document.createElement("span");
|
||||
forceText.textContent = L("B04_Surface_Field_Force");
|
||||
forceLabel.append(forceBox, forceText);
|
||||
|
||||
const analyzeButton = createButton({
|
||||
label: L("B04_Surface_Btn_Analyze"),
|
||||
variant: "filled",
|
||||
onClick: () => void onB04_Surface_Analyze_Click(),
|
||||
});
|
||||
|
||||
const leftForm = document.createElement("div");
|
||||
leftForm.className = "b04-surface__form";
|
||||
leftForm.append(
|
||||
inputFileField.root,
|
||||
filterGroup.root,
|
||||
methodGroup.root,
|
||||
forceLabel,
|
||||
analyzeButton,
|
||||
);
|
||||
shell.leftPanel.append(leftForm);
|
||||
|
||||
/* ---- 우측 결과 영역 ---- */
|
||||
const resultHeader = document.createElement("div");
|
||||
resultHeader.className = "b04-surface__result-head";
|
||||
const resultTitle = document.createElement("h3");
|
||||
resultTitle.textContent = L("B04_Surface_Result_Title");
|
||||
const refreshButton = createButton({
|
||||
label: L("B04_Surface_Btn_Refresh"),
|
||||
variant: "ghost",
|
||||
onClick: () => void onB04_Surface_Refresh_Click(),
|
||||
});
|
||||
resultHeader.append(resultTitle, refreshButton);
|
||||
|
||||
const modelList = document.createElement("div");
|
||||
modelList.className = "b04-surface__models";
|
||||
|
||||
const resultArea = document.createElement("div");
|
||||
resultArea.className = "b04-surface__result";
|
||||
resultArea.append(resultHeader, modelList);
|
||||
shell.rightArea.append(resultArea);
|
||||
|
||||
function renderModels(models: readonly SurfaceModelSummary[]): void {
|
||||
modelList.replaceChildren();
|
||||
if (models.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "b04-surface__empty";
|
||||
empty.textContent = L("B04_Surface_Result_Empty");
|
||||
modelList.append(empty);
|
||||
return;
|
||||
}
|
||||
for (const model of models) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "b04-surface__model-card";
|
||||
const head = document.createElement("div");
|
||||
head.className = "b04-surface__model-head";
|
||||
const type = document.createElement("strong");
|
||||
type.textContent = model.model_type;
|
||||
const variant =
|
||||
model.status === "CONFIRMED" ? "success" : model.status === "FAILED" ? "danger" : "neutral";
|
||||
head.append(type, createTag(model.status, variant));
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "b04-surface__model-meta";
|
||||
const resolution = document.createElement("span");
|
||||
resolution.textContent = `${L("B04_Surface_Model_Resolution")}: ${
|
||||
model.resolution_m ?? "-"
|
||||
}`;
|
||||
const path = document.createElement("span");
|
||||
path.textContent = `${L("B04_Surface_Model_Path")}: ${model.model_file_path ?? "-"}`;
|
||||
meta.append(resolution, path);
|
||||
|
||||
card.append(head, meta);
|
||||
modelList.append(card);
|
||||
}
|
||||
}
|
||||
|
||||
function getProjectId(): string | null {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
if (!projectId) {
|
||||
inputFileField.setError(L("B04_Surface_Error_Project"));
|
||||
showToast(L("B04_Surface_Error_Project"), "error");
|
||||
}
|
||||
return projectId;
|
||||
}
|
||||
|
||||
async function onB04_Surface_Analyze_Click(): Promise<void> {
|
||||
const projectId = getProjectId();
|
||||
if (!projectId) return;
|
||||
|
||||
const rawId = inputFileField.input.value.trim();
|
||||
const inputFileId = Number(rawId);
|
||||
if (!rawId || !Number.isInteger(inputFileId) || inputFileId <= 0) {
|
||||
inputFileField.setError(L("B04_Surface_Error_InputId"));
|
||||
return;
|
||||
}
|
||||
if (filterGroup.selected.size === 0 || methodGroup.selected.size === 0) {
|
||||
inputFileField.setError(L("B04_Surface_Error_Selection"));
|
||||
return;
|
||||
}
|
||||
inputFileField.setError();
|
||||
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const response = await analyzeSurface(projectId, {
|
||||
input_file_id: inputFileId,
|
||||
source_filters: [...filterGroup.selected],
|
||||
methods: [...methodGroup.selected],
|
||||
force: forceBox.checked,
|
||||
});
|
||||
showToast(
|
||||
`${L("B04_Surface_Analyze_Success")} (${response.surface_model_ids.length})`,
|
||||
"success",
|
||||
);
|
||||
await loadModels(projectId);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : L("B04_Surface_Analyze_Failed");
|
||||
inputFileField.setError(`${L("B04_Surface_Analyze_Failed")} ${detail}`);
|
||||
showToast(L("B04_Surface_Analyze_Failed"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModels(projectId: string): Promise<void> {
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const response = await listSurfaceModels(projectId);
|
||||
renderModels(response.models);
|
||||
} catch {
|
||||
showToast(L("B04_Surface_Load_Failed"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
async function onB04_Surface_Refresh_Click(): Promise<void> {
|
||||
const projectId = getProjectId();
|
||||
if (projectId) await loadModels(projectId);
|
||||
}
|
||||
|
||||
renderModels([]);
|
||||
root.replaceChildren(shell.root);
|
||||
|
||||
const initialProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
if (initialProjectId) void loadModels(initialProjectId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""B05 경로 설계 엔진 오케스트레이터.
|
||||
|
||||
경로점(BP/CP/EP/AP/FP)과 옵션을 받아 최적 경로를 계산하고, 폴리라인을
|
||||
GeoJSON으로 저장하며 DB 기록용 데이터(메타·렌더링 샘플·통계)를 준비한다.
|
||||
라우터에서 asyncio.to_thread로 호출한다.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_RidgeValley import solve_ridge_valley_route
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Solver import solve_optimal_route
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
|
||||
_ROUTE_SUBDIR = Path("B05_wf2_Route") / "route"
|
||||
# route_points 테이블에 저장할 렌더링 샘플 최대 개수
|
||||
_MAX_RENDER_POINTS = 500
|
||||
|
||||
|
||||
def _route_geojson(polyline: list[list[float]]) -> dict[str, Any]:
|
||||
"""폴리라인을 3D LineString GeoJSON Feature로 변환한다."""
|
||||
return {
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "LineString",
|
||||
"coordinates": [[round(x, 3), round(y, 3), round(z, 3)] for x, y, z in polyline],
|
||||
},
|
||||
"properties": {},
|
||||
}
|
||||
|
||||
|
||||
def _sample_render_points(
|
||||
polyline: list[list[float]], chainage_m: list[float], maximum: int
|
||||
) -> list[dict[str, Any]]:
|
||||
"""폴리라인을 최대 maximum개로 균등 샘플링해 렌더링용 포인트를 만든다."""
|
||||
n = len(polyline)
|
||||
if n == 0:
|
||||
return []
|
||||
if n <= maximum:
|
||||
indices = range(n)
|
||||
else:
|
||||
step = n / maximum
|
||||
indices = (int(i * step) for i in range(maximum))
|
||||
|
||||
points: list[dict[str, Any]] = []
|
||||
for seq, idx in enumerate(indices):
|
||||
idx = min(idx, n - 1)
|
||||
_, _, z = polyline[idx]
|
||||
# 국소 경사(%) — 직전 정점과의 차이
|
||||
slope_pct = 0.0
|
||||
if idx > 0:
|
||||
x0, y0, z0 = polyline[idx - 1]
|
||||
x1, y1, z1 = polyline[idx]
|
||||
h = ((x1 - x0) ** 2 + (y1 - y0) ** 2) ** 0.5
|
||||
if h > 1e-6:
|
||||
slope_pct = abs(z1 - z0) / h * 100.0
|
||||
points.append(
|
||||
{
|
||||
"chainage_m": round(chainage_m[idx], 3) if idx < len(chainage_m) else None,
|
||||
"elevation_m": round(z, 3),
|
||||
"slope_percent": round(slope_pct, 3),
|
||||
"sequence_num": seq,
|
||||
}
|
||||
)
|
||||
return points
|
||||
|
||||
|
||||
def run_route_design(
|
||||
project_root: Path,
|
||||
filter_key: str,
|
||||
method: str,
|
||||
smooth: bool,
|
||||
points_data: dict[str, Any],
|
||||
options: dict[str, Any],
|
||||
algorithm: str = "dijkstra",
|
||||
) -> dict[str, Any]:
|
||||
"""경로 탐색을 실행하고 GeoJSON 저장 + DB 기록용 데이터를 반환한다.
|
||||
|
||||
algorithm: "dijkstra"(격자 Dijkstra) 또는 "ridge_valley"(능선-계곡 정속경사).
|
||||
|
||||
반환 dict:
|
||||
- route_data_path: 저장한 GeoJSON의 프로젝트 상대 경로
|
||||
- solver_result: solver 원본 결과 (polyline, metrics, segments 등)
|
||||
- render_points: route_points 테이블 저장용 샘플
|
||||
- statistics: route_statistics 저장용 요약
|
||||
"""
|
||||
if algorithm == "ridge_valley":
|
||||
result = solve_ridge_valley_route(
|
||||
project_root, filter_key, smooth, points_data, options, method=method
|
||||
)
|
||||
else:
|
||||
result = solve_optimal_route(
|
||||
project_root, filter_key, smooth, points_data, options, method=method
|
||||
)
|
||||
polyline = result["polyline"]
|
||||
chainage_m = result["chainage_m"]
|
||||
|
||||
route_dir = project_root / _ROUTE_SUBDIR
|
||||
route_dir.mkdir(parents=True, exist_ok=True)
|
||||
geojson_path = route_dir / "route_main.geojson"
|
||||
atomic_write_json(geojson_path, _route_geojson(polyline))
|
||||
|
||||
# 통계 요약 (solver 메트릭에서 파생)
|
||||
metrics = result["metrics"]
|
||||
statistics = {
|
||||
"min_slope": 0.0,
|
||||
"max_slope": metrics.get("max_grade_pct"),
|
||||
"mean_slope": metrics.get("avg_grade_pct"),
|
||||
"cost_score": None,
|
||||
}
|
||||
|
||||
return {
|
||||
"route_data_path": geojson_path.relative_to(project_root).as_posix(),
|
||||
"solver_result": result,
|
||||
"render_points": _sample_render_points(polyline, chainage_m, _MAX_RENDER_POINTS),
|
||||
"statistics": statistics,
|
||||
"grade_percent": [seg.get("max_grade_pct") for seg in result.get("segments", [])],
|
||||
"constraints": result.get("conditions_snapshot", {}),
|
||||
"algorithm_params": options.get("weights") or {},
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
"""B05 경로 설계 기하 유틸리티 및 단일 구간 Dijkstra.
|
||||
|
||||
8-연결 격자에서 종단경사·측사면·곡률을 비용으로 반영하는 방향 인식
|
||||
상태공간 Dijkstra와, 곡률/거리/교차 계산 등 순수 기하 헬퍼를 제공한다.
|
||||
config에 독립적이며 모든 파라미터는 호출측에서 주입한다.
|
||||
"""
|
||||
|
||||
import heapq
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def get_neighbors(r: int, c: int, rows: int, cols: int):
|
||||
"""8-연결 이웃을 (nr, nc, dir_idx)로 순회한다. (방향 인덱스 0..7)"""
|
||||
neighbors = [
|
||||
(-1, 0, 4), # S
|
||||
(-1, 1, 3), # SE
|
||||
(0, 1, 2), # E
|
||||
(1, 1, 1), # NE
|
||||
(1, 0, 0), # N
|
||||
(1, -1, 7), # NW
|
||||
(0, -1, 6), # W
|
||||
(-1, -1, 5), # SW
|
||||
]
|
||||
for dr, dc, d_idx in neighbors:
|
||||
nr, nc = r + dr, c + dc
|
||||
if 0 <= nr < rows and 0 <= nc < cols:
|
||||
yield nr, nc, d_idx
|
||||
|
||||
|
||||
def compute_gradients(
|
||||
z_grid: np.ndarray, res_y: float, res_x: float | None = None
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""실제 격자 간격으로 지형 기울기를 계산해 (dz_dx, dz_dy)를 반환한다."""
|
||||
if res_x is None:
|
||||
res_x = res_y
|
||||
dz_dy, dz_dx = np.gradient(z_grid, res_y, res_x)
|
||||
return dz_dx, dz_dy
|
||||
|
||||
|
||||
def side_slope_magnitude(dr: int, dc: int, gx: float, gy: float) -> float:
|
||||
"""진행 방향에 수직인 지형 기울기(측사면=절토/성토 프록시)의 크기."""
|
||||
norm = math.hypot(dc, dr)
|
||||
if norm == 0:
|
||||
return 0.0
|
||||
px, py = -dr / norm, dc / norm
|
||||
return abs(gx * px + gy * py)
|
||||
|
||||
|
||||
def circumradius_2d(p0, p1, p2) -> float:
|
||||
"""연속 세 점을 지나는 수평면 외접원 반지름(중간 정점의 이산 곡률 반경)."""
|
||||
ax, ay = p0[0], p0[1]
|
||||
bx, by = p1[0], p1[1]
|
||||
cx, cy = p2[0], p2[1]
|
||||
a = math.hypot(bx - cx, by - cy)
|
||||
b = math.hypot(ax - cx, ay - cy)
|
||||
c = math.hypot(ax - bx, ay - by)
|
||||
if a < 0.01 or b < 0.01 or c < 0.01:
|
||||
return float("inf")
|
||||
area2 = abs((bx - ax) * (cy - ay) - (cx - ax) * (by - ay))
|
||||
if area2 < 1e-9:
|
||||
return float("inf")
|
||||
return (a * b * c) / (2.0 * area2)
|
||||
|
||||
|
||||
def point_to_polyline_dist_2d(px: float, py: float, polyline) -> float:
|
||||
"""점에서 폴리라인까지 최소 수평거리(점-선분 거리)."""
|
||||
if not polyline:
|
||||
return float("inf")
|
||||
best = float("inf")
|
||||
for i in range(len(polyline) - 1):
|
||||
ax, ay = polyline[i][0], polyline[i][1]
|
||||
bx, by = polyline[i + 1][0], polyline[i + 1][1]
|
||||
dx, dy = bx - ax, by - ay
|
||||
seg2 = dx * dx + dy * dy
|
||||
if seg2 <= 1e-12:
|
||||
d = math.hypot(px - ax, py - ay)
|
||||
else:
|
||||
t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / seg2))
|
||||
d = math.hypot(px - (ax + t * dx), py - (ay + t * dy))
|
||||
if d < best:
|
||||
best = d
|
||||
if len(polyline) == 1:
|
||||
best = math.hypot(px - polyline[0][0], py - polyline[0][1])
|
||||
return best
|
||||
|
||||
|
||||
def circle_intrusions(polyline, circles, labeler) -> list[dict[str, Any]]:
|
||||
"""각 원(AP/FP)에 대해 경로의 최소 이격거리와 원 내부 폴리라인 길이를 보고한다."""
|
||||
out = []
|
||||
for i, circ in enumerate(circles):
|
||||
cx, cy, radius = circ["x"], circ["y"], circ["radius_m"]
|
||||
clearance = point_to_polyline_dist_2d(cx, cy, polyline)
|
||||
intruded_len = 0.0
|
||||
for j in range(len(polyline) - 1):
|
||||
ax, ay = polyline[j][0], polyline[j][1]
|
||||
bx, by = polyline[j + 1][0], polyline[j + 1][1]
|
||||
mx, my = 0.5 * (ax + bx), 0.5 * (ay + by)
|
||||
if math.hypot(mx - cx, my - cy) < radius:
|
||||
intruded_len += math.hypot(bx - ax, by - ay)
|
||||
out.append(
|
||||
{
|
||||
"index": i,
|
||||
"label": labeler(i),
|
||||
"x": cx,
|
||||
"y": cy,
|
||||
"radius_m": radius,
|
||||
"min_clearance_m": round(clearance, 3),
|
||||
"intrusion_length_m": round(intruded_len, 3),
|
||||
"intrudes": bool(clearance < radius or intruded_len > 0.0),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def resample_polyline_2d(polyline, chainage_m, step_m: float):
|
||||
"""폴리라인을 고정 호장 간격으로 재샘플한다(도로 스케일 곡률 측정용)."""
|
||||
if len(polyline) < 2 or step_m <= 0:
|
||||
return [[p[0], p[1]] for p in polyline]
|
||||
total = chainage_m[-1]
|
||||
if total <= 0:
|
||||
return [[polyline[0][0], polyline[0][1]]]
|
||||
targets = np.arange(0.0, total + step_m * 0.5, step_m)
|
||||
out = []
|
||||
j = 0
|
||||
for t in targets:
|
||||
while j < len(chainage_m) - 2 and chainage_m[j + 1] < t:
|
||||
j += 1
|
||||
seg_len = chainage_m[j + 1] - chainage_m[j]
|
||||
frac = 0.0 if seg_len <= 1e-9 else (t - chainage_m[j]) / seg_len
|
||||
frac = min(max(frac, 0.0), 1.0)
|
||||
x = polyline[j][0] + frac * (polyline[j + 1][0] - polyline[j][0])
|
||||
y = polyline[j][1] + frac * (polyline[j + 1][1] - polyline[j][1])
|
||||
out.append([x, y])
|
||||
return out
|
||||
|
||||
|
||||
def turn_radius_from_grid(diff: int, grid_res: float) -> float:
|
||||
"""8-연결 격자의 단일 방향전환이 함의하는 곡선 반경(m) 근사."""
|
||||
if diff <= 0:
|
||||
return float("inf")
|
||||
angle_rad = math.radians(45.0 * diff)
|
||||
step_len = grid_res * (math.sqrt(2) if diff == 1 else 1.0)
|
||||
return step_len / angle_rad
|
||||
|
||||
|
||||
def single_segment_dijkstra(
|
||||
r_start: int,
|
||||
c_start: int,
|
||||
r_end: int,
|
||||
c_end: int,
|
||||
x_coords: np.ndarray,
|
||||
y_coords: np.ndarray,
|
||||
z_grid: np.ndarray,
|
||||
valid_mask: np.ndarray,
|
||||
dz_dx: np.ndarray,
|
||||
dz_dy: np.ndarray,
|
||||
ap_list: list[dict[str, Any]],
|
||||
weights: dict[str, float],
|
||||
max_grade: float,
|
||||
grid_res: float,
|
||||
min_curve_radius_m: float,
|
||||
max_uphill_grade: float | None = None,
|
||||
max_downhill_grade: float | None = None,
|
||||
) -> list[tuple[int, int]]:
|
||||
"""시작→끝 셀 방향 인식 상태공간 Dijkstra 탐색.
|
||||
|
||||
하드 제약: 종단경사 초과 링크·135도 이상 급전환은 통행불가.
|
||||
min_curve_radius_m은 방향전환 소프트 패널티로 반영된다.
|
||||
"""
|
||||
rows, cols = z_grid.shape
|
||||
w_dist = weights.get("dist", 1.0)
|
||||
w_grade = weights.get("grade", 2.0)
|
||||
w_side = weights.get("side", 1.5)
|
||||
w_curve = weights.get("curve", 0.5)
|
||||
w_avoid = weights.get("avoid", 10.0)
|
||||
|
||||
up_limit = max_uphill_grade if max_uphill_grade else max_grade
|
||||
down_limit = max_downhill_grade if max_downhill_grade else max_grade
|
||||
|
||||
dist: dict[tuple[int, int, int], float] = {}
|
||||
parent: dict[tuple[int, int, int], tuple[int, int, int]] = {}
|
||||
pq: list[tuple[float, int, int, int]] = []
|
||||
|
||||
for d in range(8):
|
||||
dist[(r_start, c_start, d)] = 0.0
|
||||
heapq.heappush(pq, (0.0, r_start, c_start, d))
|
||||
|
||||
found_dest = False
|
||||
best_dest_state = None
|
||||
|
||||
while pq:
|
||||
d_cost, r, c, d = heapq.heappop(pq)
|
||||
if d_cost > dist.get((r, c, d), float("inf")):
|
||||
continue
|
||||
if r == r_end and c == c_end:
|
||||
found_dest = True
|
||||
best_dest_state = (r, c, d)
|
||||
break
|
||||
|
||||
for nr, nc, nd in get_neighbors(r, c, rows, cols):
|
||||
if not valid_mask[nr, nc]:
|
||||
continue
|
||||
|
||||
diff = abs(d - nd)
|
||||
diff = min(diff, 8 - diff)
|
||||
if diff >= 3:
|
||||
continue # U턴/급전환은 통행불가
|
||||
|
||||
curve_penalty = 0.0
|
||||
if diff > 0:
|
||||
turn_radius = turn_radius_from_grid(diff, grid_res)
|
||||
tightness = min(min_curve_radius_m / turn_radius, 20.0)
|
||||
curve_penalty = w_curve * tightness * grid_res
|
||||
|
||||
is_diagonal = nd % 2 != 0
|
||||
h_dist = grid_res * math.sqrt(2) if is_diagonal else grid_res
|
||||
|
||||
z_curr = z_grid[r, c]
|
||||
z_next = z_grid[nr, nc]
|
||||
dz = z_next - z_curr
|
||||
grade = abs(dz) / h_dist
|
||||
|
||||
applicable_limit = up_limit if dz > 0 else down_limit
|
||||
if grade > applicable_limit:
|
||||
continue
|
||||
f_grade = (grade / applicable_limit) ** 2 * 10.0
|
||||
|
||||
side_slope = side_slope_magnitude(nr - r, nc - c, dz_dx[nr, nc], dz_dy[nr, nc])
|
||||
f_side = side_slope * 5.0
|
||||
|
||||
nx_model = x_coords[nc]
|
||||
ny_model = y_coords[nr]
|
||||
avoid_penalty = 0.0
|
||||
for ap in ap_list:
|
||||
dist_to_ap = math.hypot(nx_model - ap["x"], ny_model - ap["y"])
|
||||
if dist_to_ap < ap["radius_m"]:
|
||||
avoid_penalty += w_avoid * 1000.0 * h_dist
|
||||
|
||||
link_cost = (
|
||||
w_dist * h_dist
|
||||
+ w_grade * f_grade * h_dist
|
||||
+ w_side * f_side * h_dist
|
||||
+ curve_penalty
|
||||
+ avoid_penalty
|
||||
)
|
||||
next_cost = d_cost + link_cost
|
||||
state_next = (nr, nc, nd)
|
||||
if next_cost < dist.get(state_next, float("inf")):
|
||||
dist[state_next] = next_cost
|
||||
parent[state_next] = (r, c, d)
|
||||
heapq.heappush(pq, (next_cost, nr, nc, nd))
|
||||
|
||||
if not found_dest:
|
||||
return []
|
||||
|
||||
path = []
|
||||
curr = best_dest_state
|
||||
while curr:
|
||||
path.append((curr[0], curr[1]))
|
||||
curr = parent.get(curr)
|
||||
return path[::-1]
|
||||
@@ -0,0 +1,783 @@
|
||||
"""B05 능선-계곡 정속경사 임도 길찾기 (대안 알고리즘).
|
||||
|
||||
격자 Dijkstra와 분리된 방식: 지형 스켈레톤에서 주/지 능선·계곡 polyline을
|
||||
얻고, 능선↔계곡 노드 쌍을 잇는 정속경사 직선 세그먼트로 그래프를 만든 뒤
|
||||
교각 페널티 Dijkstra로 노드 시퀀스를 탐색한다. 최종 선형은 직선+최소회전반경
|
||||
원호(fillet)로 구성한다. 반환 스키마는 solve_optimal_route()와 동일하다.
|
||||
"""
|
||||
|
||||
import heapq
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Geometry import (
|
||||
circle_intrusions,
|
||||
circumradius_2d,
|
||||
point_to_polyline_dist_2d,
|
||||
resample_polyline_2d,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Skeleton import load_or_build_skeleton
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Solver import (
|
||||
_MODELS_SUBDIR,
|
||||
_load_or_build_cost_surface,
|
||||
)
|
||||
from config.config_system import (
|
||||
FOREST_ROAD_MIN_CURVE_R_M,
|
||||
ROUTE_ALT_GRADE_TOLERANCE,
|
||||
ROUTE_ALT_MAX_GRADE,
|
||||
ROUTE_ALT_MIN_GRADE,
|
||||
ROUTE_DEFAULT_GRADE_CLASS,
|
||||
ROUTE_REQUIRED_POINT_TOLERANCE_M,
|
||||
SKELETON_NODE_SPACING_M,
|
||||
)
|
||||
|
||||
# 엣지 후보 탐색 파라미터 (알고리즘 내부 상수)
|
||||
MAX_EDGE_LEN_M = 400.0
|
||||
MIN_EDGE_LEN_M = 20.0
|
||||
MAX_NEIGHBORS_PER_NODE = 16
|
||||
TURN_PENALTY_W = 60.0
|
||||
MAX_TURN_DEG = 120.0
|
||||
|
||||
|
||||
class _Grid:
|
||||
"""비용면 격자에 대한 표고/유효성 조회 헬퍼."""
|
||||
|
||||
def __init__(self, x, y, z, valid, grid_res):
|
||||
self.x = np.asarray(x, dtype=np.float64)
|
||||
self.y = np.asarray(y, dtype=np.float64)
|
||||
self.z = np.asarray(z, dtype=np.float64)
|
||||
self.valid = np.asarray(valid, dtype=bool)
|
||||
self.res = float(grid_res)
|
||||
|
||||
def _idx(self, coords: np.ndarray, v: float) -> int:
|
||||
i = int(np.clip(np.searchsorted(coords, v), 0, len(coords) - 1))
|
||||
j = max(i - 1, 0)
|
||||
return j if abs(v - coords[j]) <= abs(coords[i] - v) else i
|
||||
|
||||
def rc(self, px: float, py: float) -> tuple[int, int]:
|
||||
return self._idx(self.y, py), self._idx(self.x, px)
|
||||
|
||||
def z_at(self, px: float, py: float) -> float:
|
||||
r, c = self.rc(px, py)
|
||||
return float(self.z[r, c])
|
||||
|
||||
def valid_at(self, px: float, py: float) -> bool:
|
||||
in_bounds = (self.x[0] <= px <= self.x[-1]) and (self.y[0] <= py <= self.y[-1])
|
||||
if not in_bounds:
|
||||
return False
|
||||
r, c = self.rc(px, py)
|
||||
return bool(self.valid[r, c])
|
||||
|
||||
|
||||
def _collect_nodes(
|
||||
skeleton: dict[str, Any], spacing_m: float
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""능선/계곡 polyline 정점을 spacing 간격으로 다운샘플해 노드 배열을 만든다."""
|
||||
pos, kind, on_main = [], [], []
|
||||
|
||||
def _add(polys, k, is_main):
|
||||
for item in polys:
|
||||
pl = item["polyline"]
|
||||
acc = spacing_m
|
||||
prev = None
|
||||
for p in pl:
|
||||
step = spacing_m if prev is None else math.hypot(p[0] - prev[0], p[1] - prev[1])
|
||||
acc += step
|
||||
prev = p
|
||||
if acc >= spacing_m:
|
||||
acc = 0.0
|
||||
pos.append([p[0], p[1], p[2]])
|
||||
kind.append(k)
|
||||
on_main.append(is_main)
|
||||
|
||||
_add(skeleton.get("minor_ridge", []), 0, False)
|
||||
_add(skeleton.get("minor_valley", []), 1, False)
|
||||
_add(skeleton.get("main_ridge", []), 0, True)
|
||||
_add(skeleton.get("main_valley", []), 1, True)
|
||||
|
||||
if not pos:
|
||||
return (np.zeros((0, 3)), np.zeros(0, dtype=np.int8), np.zeros(0, dtype=bool))
|
||||
return (
|
||||
np.asarray(pos, dtype=np.float64),
|
||||
np.asarray(kind, dtype=np.int8),
|
||||
np.asarray(on_main, dtype=bool),
|
||||
)
|
||||
|
||||
|
||||
def _build_barrier_mask(grid: _Grid, skeleton: dict[str, Any]) -> np.ndarray:
|
||||
"""주능선/주계곡 셀을 True로 표시한 구획 경계 마스크."""
|
||||
barrier = np.zeros(grid.z.shape, dtype=bool)
|
||||
for key in ("main_ridge", "main_valley"):
|
||||
for item in skeleton.get(key, []):
|
||||
for p in item["polyline"]:
|
||||
r, c = grid.rc(p[0], p[1])
|
||||
barrier[r, c] = True
|
||||
return barrier
|
||||
|
||||
|
||||
def _segment_feasible(
|
||||
a: np.ndarray,
|
||||
b: np.ndarray,
|
||||
grid: _Grid,
|
||||
barrier: np.ndarray,
|
||||
blocked_circles: list[dict[str, float]],
|
||||
min_grade: float,
|
||||
max_grade: float,
|
||||
tol: float,
|
||||
endpoint_free_m: float,
|
||||
enforce_grade_window: bool = True,
|
||||
) -> bool:
|
||||
"""a→b 직선이 정속경사 세그먼트로 성립하는지 검사한다."""
|
||||
dx, dy = b[0] - a[0], b[1] - a[1]
|
||||
length = math.hypot(dx, dy)
|
||||
if length < 1e-6:
|
||||
return False
|
||||
design_grade = (b[2] - a[2]) / length
|
||||
g = abs(design_grade)
|
||||
if enforce_grade_window:
|
||||
if not (min_grade <= g <= max_grade):
|
||||
return False
|
||||
elif g > max_grade:
|
||||
return False
|
||||
|
||||
step = max(grid.res, 1.0)
|
||||
n_steps = max(int(length / step), 1)
|
||||
for i in range(n_steps + 1):
|
||||
t = i / n_steps
|
||||
px, py = a[0] + t * dx, a[1] + t * dy
|
||||
if not grid.valid_at(px, py):
|
||||
return False
|
||||
s = t * length
|
||||
z_design = a[2] + design_grade * s
|
||||
z_terrain = grid.z_at(px, py)
|
||||
allowed = tol * max(s, length - s) + grid.res
|
||||
if abs(z_terrain - z_design) > allowed:
|
||||
return False
|
||||
if min(s, length - s) > endpoint_free_m:
|
||||
r, c = grid.rc(px, py)
|
||||
if barrier[r, c]:
|
||||
return False
|
||||
for circ in blocked_circles:
|
||||
if math.hypot(px - circ["x"], py - circ["y"]) < circ["radius_m"]:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _build_edges(
|
||||
pos: np.ndarray,
|
||||
kind: np.ndarray,
|
||||
grid: _Grid,
|
||||
barrier: np.ndarray,
|
||||
blocked_circles: list[dict[str, float]],
|
||||
min_grade: float,
|
||||
max_grade: float,
|
||||
tol: float,
|
||||
endpoint_free_m: float,
|
||||
) -> dict[int, list[tuple[int, float]]]:
|
||||
"""능선↔계곡 노드 쌍의 정속경사 직선 엣지를 만든다 (무방향, 길이 저장)."""
|
||||
from scipy.spatial import cKDTree
|
||||
|
||||
adj: dict[int, list[tuple[int, float]]] = {i: [] for i in range(len(pos))}
|
||||
if len(pos) == 0:
|
||||
return adj
|
||||
|
||||
ridge_idx = np.nonzero(kind == 0)[0]
|
||||
valley_idx = np.nonzero(kind == 1)[0]
|
||||
if len(ridge_idx) == 0 or len(valley_idx) == 0:
|
||||
return adj
|
||||
|
||||
valley_tree = cKDTree(pos[valley_idx, :2])
|
||||
for ri in ridge_idx:
|
||||
cand = valley_tree.query_ball_point(pos[ri, :2], MAX_EDGE_LEN_M)
|
||||
cand = sorted(
|
||||
cand,
|
||||
key=lambda j: (
|
||||
(pos[ri, 0] - pos[valley_idx[j], 0]) ** 2
|
||||
+ (pos[ri, 1] - pos[valley_idx[j], 1]) ** 2
|
||||
),
|
||||
)
|
||||
added = 0
|
||||
for j in cand:
|
||||
vi = int(valley_idx[j])
|
||||
length = math.hypot(pos[ri, 0] - pos[vi, 0], pos[ri, 1] - pos[vi, 1])
|
||||
if length < MIN_EDGE_LEN_M:
|
||||
continue
|
||||
if pos[ri, 2] <= pos[vi, 2]:
|
||||
continue
|
||||
if not _segment_feasible(
|
||||
pos[ri],
|
||||
pos[vi],
|
||||
grid,
|
||||
barrier,
|
||||
blocked_circles,
|
||||
min_grade,
|
||||
max_grade,
|
||||
tol,
|
||||
endpoint_free_m,
|
||||
):
|
||||
continue
|
||||
adj[int(ri)].append((vi, length))
|
||||
adj[vi].append((int(ri), length))
|
||||
added += 1
|
||||
if added >= MAX_NEIGHBORS_PER_NODE:
|
||||
break
|
||||
return adj
|
||||
|
||||
|
||||
def _endpoint_connectors(
|
||||
pt: dict[str, float],
|
||||
pos: np.ndarray,
|
||||
grid: _Grid,
|
||||
barrier: np.ndarray,
|
||||
blocked_circles: list[dict[str, float]],
|
||||
max_uphill_grade: float,
|
||||
max_downhill_grade: float,
|
||||
tol: float,
|
||||
endpoint_free_m: float,
|
||||
) -> list[tuple[int, float]]:
|
||||
"""BP/CP/EP를 그래프 노드에 잇는 연결 세그먼트 후보."""
|
||||
from scipy.spatial import cKDTree
|
||||
|
||||
if len(pos) == 0:
|
||||
return []
|
||||
p = np.array([pt["x"], pt["y"], grid.z_at(pt["x"], pt["y"])])
|
||||
tree = cKDTree(pos[:, :2])
|
||||
cand = tree.query_ball_point(p[:2], MAX_EDGE_LEN_M)
|
||||
cand = sorted(cand, key=lambda j: (p[0] - pos[j, 0]) ** 2 + (p[1] - pos[j, 1]) ** 2)
|
||||
out = []
|
||||
for j in cand:
|
||||
length = math.hypot(p[0] - pos[j, 0], p[1] - pos[j, 1])
|
||||
if length < 1e-6:
|
||||
out.append((int(j), max(length, 0.01)))
|
||||
continue
|
||||
applicable = max_uphill_grade if pos[j, 2] > p[2] else max_downhill_grade
|
||||
if _segment_feasible(
|
||||
p,
|
||||
pos[j],
|
||||
grid,
|
||||
barrier,
|
||||
blocked_circles,
|
||||
0.0,
|
||||
applicable,
|
||||
tol,
|
||||
endpoint_free_m,
|
||||
enforce_grade_window=False,
|
||||
):
|
||||
out.append((int(j), length))
|
||||
if len(out) >= MAX_NEIGHBORS_PER_NODE:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _turn_angle(p_prev, p_curr, p_next) -> float:
|
||||
"""진행방향 변화(교각) [rad]. 0 = 직진."""
|
||||
v1 = (p_curr[0] - p_prev[0], p_curr[1] - p_prev[1])
|
||||
v2 = (p_next[0] - p_curr[0], p_next[1] - p_curr[1])
|
||||
n1, n2 = math.hypot(*v1), math.hypot(*v2)
|
||||
if n1 < 1e-9 or n2 < 1e-9:
|
||||
return 0.0
|
||||
cosang = max(-1.0, min(1.0, (v1[0] * v2[0] + v1[1] * v2[1]) / (n1 * n2)))
|
||||
return math.acos(cosang)
|
||||
|
||||
|
||||
def _search_segment(
|
||||
start_pt: dict[str, float],
|
||||
end_pt: dict[str, float],
|
||||
pos: np.ndarray,
|
||||
adj: dict[int, list[tuple[int, float]]],
|
||||
grid: _Grid,
|
||||
barrier: np.ndarray,
|
||||
blocked_circles: list[dict[str, float]],
|
||||
max_uphill_grade: float,
|
||||
max_downhill_grade: float,
|
||||
tol: float,
|
||||
endpoint_free_m: float,
|
||||
min_radius: float,
|
||||
) -> list[list[float]] | None:
|
||||
"""start→end를 그래프 위에서 탐색해 노드 좌표 시퀀스를 반환 (실패 시 None)."""
|
||||
start_xyz = [start_pt["x"], start_pt["y"], grid.z_at(start_pt["x"], start_pt["y"])]
|
||||
end_xyz = [end_pt["x"], end_pt["y"], grid.z_at(end_pt["x"], end_pt["y"])]
|
||||
|
||||
direct_limit = max_uphill_grade if end_xyz[2] > start_xyz[2] else max_downhill_grade
|
||||
if _segment_feasible(
|
||||
np.asarray(start_xyz),
|
||||
np.asarray(end_xyz),
|
||||
grid,
|
||||
barrier,
|
||||
blocked_circles,
|
||||
0.0,
|
||||
direct_limit,
|
||||
tol,
|
||||
endpoint_free_m,
|
||||
enforce_grade_window=False,
|
||||
):
|
||||
return [start_xyz, end_xyz]
|
||||
|
||||
start_conn = _endpoint_connectors(
|
||||
start_pt,
|
||||
pos,
|
||||
grid,
|
||||
barrier,
|
||||
blocked_circles,
|
||||
max_uphill_grade,
|
||||
max_downhill_grade,
|
||||
tol,
|
||||
endpoint_free_m,
|
||||
)
|
||||
end_conn = _endpoint_connectors(
|
||||
end_pt,
|
||||
pos,
|
||||
grid,
|
||||
barrier,
|
||||
blocked_circles,
|
||||
max_uphill_grade,
|
||||
max_downhill_grade,
|
||||
tol,
|
||||
endpoint_free_m,
|
||||
)
|
||||
if not start_conn or not end_conn:
|
||||
return None
|
||||
end_conn_map = {j: length for j, length in end_conn}
|
||||
|
||||
START, MAX_TURN = -1, math.radians(MAX_TURN_DEG)
|
||||
|
||||
def _xy(i):
|
||||
return start_xyz if i == START else pos[i]
|
||||
|
||||
def _seg_len(i, j):
|
||||
a, b = _xy(i), _xy(j)
|
||||
return math.hypot(a[0] - b[0], a[1] - b[1])
|
||||
|
||||
def _fillet_ok(theta, len_in, len_out):
|
||||
if theta < 1e-6:
|
||||
return True
|
||||
t_len = min_radius * math.tan(theta / 2.0)
|
||||
return t_len <= len_in / 2.0 and t_len <= len_out / 2.0
|
||||
|
||||
dist: dict[tuple[int, int], float] = {}
|
||||
parent: dict[tuple[int, int], tuple[int, int]] = {}
|
||||
pq: list[tuple[float, int, int]] = []
|
||||
for j, length in start_conn:
|
||||
dist[(START, j)] = length
|
||||
heapq.heappush(pq, (length, START, j))
|
||||
|
||||
best_state, best_cost = None, float("inf")
|
||||
while pq:
|
||||
d, u, v = heapq.heappop(pq)
|
||||
if d > dist.get((u, v), float("inf")):
|
||||
continue
|
||||
if v in end_conn_map:
|
||||
theta = _turn_angle(_xy(u), _xy(v), end_xyz)
|
||||
if theta <= MAX_TURN and _fillet_ok(theta, _seg_len(u, v), end_conn_map[v]):
|
||||
total = d + end_conn_map[v] + TURN_PENALTY_W * theta
|
||||
if total < best_cost:
|
||||
best_cost, best_state = total, (u, v)
|
||||
for w, length in adj.get(v, []):
|
||||
if w == u:
|
||||
continue
|
||||
theta = _turn_angle(_xy(u), _xy(v), pos[w])
|
||||
if theta > MAX_TURN:
|
||||
continue
|
||||
if not _fillet_ok(theta, _seg_len(u, v), length):
|
||||
continue
|
||||
nd = d + length + TURN_PENALTY_W * theta
|
||||
if nd < dist.get((v, w), float("inf")):
|
||||
dist[(v, w)] = nd
|
||||
parent[(v, w)] = (u, v)
|
||||
heapq.heappush(pq, (nd, v, w))
|
||||
|
||||
if best_state is None:
|
||||
return None
|
||||
seq = [end_xyz]
|
||||
state = best_state
|
||||
while state is not None:
|
||||
u, v = state
|
||||
seq.append([float(pos[v][0]), float(pos[v][1]), float(pos[v][2])])
|
||||
state = parent.get(state)
|
||||
if state is None and u == START:
|
||||
break
|
||||
seq.append(start_xyz)
|
||||
return seq[::-1]
|
||||
|
||||
|
||||
def _fillet_alignment(
|
||||
nodes: list[list[float]], radius: float, step_m: float
|
||||
) -> tuple[list[list[float]], list[float]]:
|
||||
"""노드 시퀀스를 직선 + 최소회전반경 원호(fillet) 선형으로 변환한다."""
|
||||
n = len(nodes)
|
||||
if n < 2:
|
||||
return [list(p) for p in nodes], [float("inf")] * n
|
||||
|
||||
seg_len, seg_grade = [], []
|
||||
for i in range(n - 1):
|
||||
length = math.hypot(nodes[i + 1][0] - nodes[i][0], nodes[i + 1][1] - nodes[i][1])
|
||||
seg_len.append(max(length, 1e-9))
|
||||
seg_grade.append((nodes[i + 1][2] - nodes[i][2]) / max(length, 1e-9))
|
||||
|
||||
t_len = [0.0] * n
|
||||
theta = [0.0] * n
|
||||
for i in range(1, n - 1):
|
||||
th = _turn_angle(nodes[i - 1], nodes[i], nodes[i + 1])
|
||||
theta[i] = th
|
||||
if th < 1e-6:
|
||||
continue
|
||||
t = radius * math.tan(th / 2.0)
|
||||
t_len[i] = min(t, seg_len[i - 1] / 2.0, seg_len[i] / 2.0)
|
||||
|
||||
poly: list[list[float]] = []
|
||||
radii: list[float] = []
|
||||
|
||||
def _append(pt, rad):
|
||||
poly.append([float(pt[0]), float(pt[1]), float(pt[2])])
|
||||
radii.append(rad)
|
||||
|
||||
_append(nodes[0], float("inf"))
|
||||
for i in range(n - 1):
|
||||
ax, ay, az = nodes[i]
|
||||
bx, by, bz = nodes[i + 1]
|
||||
length, g = seg_len[i], seg_grade[i]
|
||||
ux, uy = (bx - ax) / length, (by - ay) / length
|
||||
s0, s1 = t_len[i], length - t_len[i + 1]
|
||||
n_pts = max(int((s1 - s0) / step_m), 1)
|
||||
for k in range(n_pts + 1):
|
||||
s = s0 + (s1 - s0) * (k / n_pts)
|
||||
_append((ax + ux * s, ay + uy * s, az + g * s), float("inf"))
|
||||
if i < n - 2 and t_len[i + 1] > 1e-9 and theta[i + 1] > 1e-6:
|
||||
th = theta[i + 1]
|
||||
t = t_len[i + 1]
|
||||
eff_r = t / math.tan(th / 2.0)
|
||||
cx0, cy0 = bx - ux * t, by - uy * t
|
||||
nx_, ny_, _nz = nodes[i + 2]
|
||||
length2 = seg_len[i + 1]
|
||||
vx, vy = (nx_ - bx) / length2, (ny_ - by) / length2
|
||||
cx1, cy1 = bx + vx * t, by + vy * t
|
||||
z_in = az + g * (length - t)
|
||||
z_out = bz + seg_grade[i + 1] * t
|
||||
arc_len = eff_r * th
|
||||
n_arc = max(int(arc_len / step_m), 2)
|
||||
cross = ux * vy - uy * vx
|
||||
sign = 1.0 if cross >= 0 else -1.0
|
||||
ox, oy = cx0 + (-uy * sign) * eff_r, cy0 + (ux * sign) * eff_r
|
||||
ang0 = math.atan2(cy0 - oy, cx0 - ox)
|
||||
for k in range(1, n_arc):
|
||||
a = ang0 + sign * th * (k / n_arc)
|
||||
frac = k / n_arc
|
||||
_append(
|
||||
(
|
||||
ox + eff_r * math.cos(a),
|
||||
oy + eff_r * math.sin(a),
|
||||
z_in + (z_out - z_in) * frac,
|
||||
),
|
||||
eff_r,
|
||||
)
|
||||
_ = (cx1, cy1)
|
||||
_append(nodes[-1], float("inf"))
|
||||
|
||||
cleaned, cleaned_r = [poly[0]], [radii[0]]
|
||||
for p, r in zip(poly[1:], radii[1:]):
|
||||
if math.hypot(p[0] - cleaned[-1][0], p[1] - cleaned[-1][1]) > 0.05:
|
||||
cleaned.append(p)
|
||||
cleaned_r.append(r)
|
||||
if len(cleaned) >= 2:
|
||||
cleaned[-1] = poly[-1]
|
||||
return cleaned, cleaned_r
|
||||
|
||||
|
||||
def resolve_grade_bounds(options: dict[str, Any]) -> dict[str, float]:
|
||||
"""options에서 방향별 경사 상/하한을 해석한다."""
|
||||
|
||||
def _opt(key: str, default: float) -> float:
|
||||
v = options.get(key)
|
||||
return float(v) if v is not None else default
|
||||
|
||||
min_uphill_grade = _opt("min_uphill_grade", ROUTE_ALT_MIN_GRADE)
|
||||
min_downhill_grade = _opt("min_downhill_grade", ROUTE_ALT_MIN_GRADE)
|
||||
max_uphill_grade = _opt("max_uphill_grade", ROUTE_ALT_MAX_GRADE)
|
||||
max_downhill_grade = _opt("max_downhill_grade", ROUTE_ALT_MAX_GRADE)
|
||||
return {
|
||||
"min_uphill_grade": min_uphill_grade,
|
||||
"min_downhill_grade": min_downhill_grade,
|
||||
"max_uphill_grade": max_uphill_grade,
|
||||
"max_downhill_grade": max_downhill_grade,
|
||||
"min_grade_for_edges": max(min_uphill_grade, min_downhill_grade),
|
||||
"max_grade_for_edges": min(max_uphill_grade, max_downhill_grade),
|
||||
}
|
||||
|
||||
|
||||
def solve_ridge_valley_route(
|
||||
project_root: Path,
|
||||
filter_key: str,
|
||||
smooth: bool,
|
||||
points_data: dict[str, Any],
|
||||
options: dict[str, Any],
|
||||
method: str = "dtm",
|
||||
) -> dict[str, Any]:
|
||||
"""능선-계곡 정속경사 방식으로 BP→(CP…)→EP 경로를 계산한다."""
|
||||
project_root = Path(project_root)
|
||||
models_dir = project_root / _MODELS_SUBDIR
|
||||
|
||||
(x_coords, y_coords, z_grid, valid_mask, _dz_dx, _dz_dy, grid_res) = (
|
||||
_load_or_build_cost_surface(project_root, models_dir, filter_key, method, smooth)
|
||||
)
|
||||
grid = _Grid(x_coords, y_coords, z_grid, valid_mask, grid_res)
|
||||
|
||||
bp = points_data.get("bp")
|
||||
ep = points_data.get("ep")
|
||||
cp_list = sorted(points_data.get("cp", []), key=lambda x: x.get("order", 0))
|
||||
ap_list = points_data.get("ap", [])
|
||||
fp_list = points_data.get("fp", [])
|
||||
if not bp or not ep:
|
||||
raise ValueError("BP/EP 가 배치되어 있지 않습니다.")
|
||||
|
||||
skeleton = load_or_build_skeleton(project_root, filter_key, method, smooth)
|
||||
barrier = _build_barrier_mask(grid, skeleton)
|
||||
|
||||
gb = resolve_grade_bounds(options)
|
||||
min_uphill_grade = gb["min_uphill_grade"]
|
||||
min_downhill_grade = gb["min_downhill_grade"]
|
||||
max_uphill_grade = gb["max_uphill_grade"]
|
||||
max_downhill_grade = gb["max_downhill_grade"]
|
||||
max_grade = gb["max_grade_for_edges"]
|
||||
min_grade = gb["min_grade_for_edges"]
|
||||
tol = float(options.get("alt_grade_tolerance") or ROUTE_ALT_GRADE_TOLERANCE)
|
||||
min_curve_radius_m = options.get("min_curve_radius_m")
|
||||
if not min_curve_radius_m or min_curve_radius_m <= 0:
|
||||
grade_class = options.get("grade_class", ROUTE_DEFAULT_GRADE_CLASS)
|
||||
min_curve_radius_m = FOREST_ROAD_MIN_CURVE_R_M.get(grade_class, 12.0)
|
||||
min_curve_radius_m = float(min_curve_radius_m)
|
||||
endpoint_free_m = float(SKELETON_NODE_SPACING_M) * 1.5
|
||||
|
||||
blocked = list(fp_list)
|
||||
if not options.get("allow_avoid_pass_through", False):
|
||||
blocked = blocked + list(ap_list)
|
||||
|
||||
pos, kind, _on_main = _collect_nodes(skeleton, float(SKELETON_NODE_SPACING_M))
|
||||
adj = _build_edges(
|
||||
pos, kind, grid, barrier, blocked, min_grade, max_grade, tol, endpoint_free_m
|
||||
)
|
||||
|
||||
sequence = [bp] + cp_list + [ep]
|
||||
|
||||
def _label(idx):
|
||||
if idx == 0:
|
||||
return "BP"
|
||||
if idx == len(sequence) - 1:
|
||||
return "EP"
|
||||
return f"CP{sequence[idx].get('order', idx)}"
|
||||
|
||||
all_nodes: list[list[float]] = []
|
||||
node_seg_marks: list[int] = []
|
||||
for i in range(len(sequence) - 1):
|
||||
seg_nodes = _search_segment(
|
||||
sequence[i],
|
||||
sequence[i + 1],
|
||||
pos,
|
||||
adj,
|
||||
grid,
|
||||
barrier,
|
||||
blocked,
|
||||
max_uphill_grade,
|
||||
max_downhill_grade,
|
||||
tol,
|
||||
endpoint_free_m,
|
||||
min_curve_radius_m,
|
||||
)
|
||||
if not seg_nodes:
|
||||
raise ValueError(
|
||||
f"세그먼트 {i + 1} ({_label(i)} -> {_label(i + 1)}) 능선-계곡 정속경사 경로 "
|
||||
f"탐색 실패: 오르막 {min_uphill_grade * 100:.0f}~{max_uphill_grade * 100:.0f}%·"
|
||||
f"내리막 {min_downhill_grade * 100:.0f}~{max_downhill_grade * 100:.0f}%·"
|
||||
f"허용오차 ±{tol * 100:.1f}%·최소곡선반지름 {min_curve_radius_m:.0f}m 제약으로 "
|
||||
f"성립하는 지능선-지계곡 연결이 없습니다."
|
||||
)
|
||||
if i == 0:
|
||||
all_nodes.extend(seg_nodes)
|
||||
else:
|
||||
all_nodes.extend(seg_nodes[1:])
|
||||
node_seg_marks.append(len(all_nodes) - 1)
|
||||
|
||||
polyline, _vertex_radii = _fillet_alignment(
|
||||
all_nodes, min_curve_radius_m, step_m=max(grid.res, 2.0)
|
||||
)
|
||||
|
||||
n = len(polyline)
|
||||
chainage_m = [0.0] * n
|
||||
length_m = 0.0
|
||||
max_grade_pct = 0.0
|
||||
max_uphill_pct = 0.0
|
||||
max_downhill_pct = 0.0
|
||||
grade_sums = 0.0
|
||||
slope_violations = 0
|
||||
for i in range(n - 1):
|
||||
x1, y1, z1 = polyline[i]
|
||||
x2, y2, z2 = polyline[i + 1]
|
||||
hd = math.hypot(x2 - x1, y2 - y1)
|
||||
chainage_m[i + 1] = chainage_m[i] + hd
|
||||
if hd > 0.01:
|
||||
dz = z2 - z1
|
||||
s = abs(dz) / hd
|
||||
length_m += hd
|
||||
grade_sums += s * hd
|
||||
max_grade_pct = max(max_grade_pct, s)
|
||||
if dz > 0:
|
||||
max_uphill_pct = max(max_uphill_pct, s)
|
||||
applicable = max_uphill_grade
|
||||
else:
|
||||
max_downhill_pct = max(max_downhill_pct, s)
|
||||
applicable = max_downhill_grade
|
||||
if s > applicable + tol:
|
||||
slope_violations += 1
|
||||
avg_grade_pct = (grade_sums / length_m) if length_m > 0 else 0.0
|
||||
|
||||
curve_check_step = max(2.0 * grid.res, 4.0)
|
||||
resampled = resample_polyline_2d(polyline, chainage_m, curve_check_step)
|
||||
total_chainage = chainage_m[-1] if chainage_m else 0.0
|
||||
curve_violations = 0
|
||||
min_curve_radius_actual = float("inf")
|
||||
radii = [float("inf")] * len(resampled)
|
||||
for i in range(1, len(resampled) - 1):
|
||||
radius = circumradius_2d(resampled[i - 1], resampled[i], resampled[i + 1])
|
||||
radii[i] = radius
|
||||
min_curve_radius_actual = min(min_curve_radius_actual, radius)
|
||||
if radius < min_curve_radius_m * 0.99:
|
||||
curve_violations += 1
|
||||
|
||||
curve_warning_segments = []
|
||||
run_start = None
|
||||
for i in range(1, len(resampled)):
|
||||
violating = i < len(resampled) - 1 and radii[i] < min_curve_radius_m * 0.99
|
||||
if violating and run_start is None:
|
||||
run_start = i
|
||||
if (not violating) and run_start is not None:
|
||||
run_end = i - 1
|
||||
ch_s = min(run_start * curve_check_step, total_chainage)
|
||||
ch_e = min(run_end * curve_check_step, total_chainage)
|
||||
curve_warning_segments.append(
|
||||
{
|
||||
"chainage_start_m": round(ch_s, 2),
|
||||
"chainage_end_m": round(ch_e, 2),
|
||||
"min_radius_m": round(min(radii[run_start : run_end + 1]), 2),
|
||||
"required_radius_m": round(min_curve_radius_m, 2),
|
||||
"polyline_start_index": int(np.searchsorted(chainage_m, ch_s)),
|
||||
"polyline_end_index": int(np.searchsorted(chainage_m, ch_e)),
|
||||
}
|
||||
)
|
||||
run_start = None
|
||||
|
||||
def _nearest_vertex(pt) -> int:
|
||||
best, best_d = 0, float("inf")
|
||||
for i, p in enumerate(polyline):
|
||||
d = (p[0] - pt[0]) ** 2 + (p[1] - pt[1]) ** 2
|
||||
if d < best_d:
|
||||
best, best_d = i, d
|
||||
return best
|
||||
|
||||
segments = []
|
||||
prev_idx = 0
|
||||
for si, mark in enumerate(node_seg_marks):
|
||||
end_idx = _nearest_vertex(all_nodes[mark])
|
||||
s, e = prev_idx, max(end_idx, prev_idx)
|
||||
seg_max_grade = 0.0
|
||||
for i in range(s, e):
|
||||
x1, y1, z1 = polyline[i]
|
||||
x2, y2, z2 = polyline[i + 1]
|
||||
hd = math.hypot(x2 - x1, y2 - y1)
|
||||
if hd > 0.01:
|
||||
seg_max_grade = max(seg_max_grade, abs(z2 - z1) / hd)
|
||||
segments.append(
|
||||
{
|
||||
"index": si,
|
||||
"from": _label(si),
|
||||
"to": _label(si + 1),
|
||||
"point_start": s,
|
||||
"point_end": e,
|
||||
"chainage_start_m": round(chainage_m[s], 2),
|
||||
"chainage_end_m": round(chainage_m[e], 2),
|
||||
"length_m": round(chainage_m[e] - chainage_m[s], 2),
|
||||
"max_grade_pct": round(seg_max_grade * 100, 2),
|
||||
}
|
||||
)
|
||||
prev_idx = e
|
||||
|
||||
tol_req = ROUTE_REQUIRED_POINT_TOLERANCE_M
|
||||
required_point_checks = []
|
||||
for idx, pt in enumerate(sequence):
|
||||
d = point_to_polyline_dist_2d(pt["x"], pt["y"], polyline)
|
||||
required_point_checks.append(
|
||||
{
|
||||
"point": _label(idx),
|
||||
"x": pt["x"],
|
||||
"y": pt["y"],
|
||||
"distance_m": round(d, 3),
|
||||
"snap_distance_m": 0.0,
|
||||
"point_on_valid_terrain": grid.valid_at(pt["x"], pt["y"]),
|
||||
"tolerance_m": tol_req,
|
||||
"within_tolerance": bool(d <= tol_req),
|
||||
}
|
||||
)
|
||||
required_points_ok = all(c["within_tolerance"] for c in required_point_checks)
|
||||
|
||||
avoid_intrusions = circle_intrusions(polyline, ap_list, lambda i: f"AP{i + 1}")
|
||||
forbidden_intrusions = circle_intrusions(polyline, fp_list, lambda i: f"FP{i + 1}")
|
||||
|
||||
constant_grade_segments = []
|
||||
for i in range(len(all_nodes) - 1):
|
||||
length = math.hypot(
|
||||
all_nodes[i + 1][0] - all_nodes[i][0], all_nodes[i + 1][1] - all_nodes[i][1]
|
||||
)
|
||||
if length > 0.01:
|
||||
constant_grade_segments.append(
|
||||
{
|
||||
"index": i,
|
||||
"length_m": round(length, 2),
|
||||
"grade_pct": round((all_nodes[i + 1][2] - all_nodes[i][2]) / length * 100, 2),
|
||||
}
|
||||
)
|
||||
|
||||
conditions_snapshot = {
|
||||
"filter": filter_key,
|
||||
"method": method,
|
||||
"smooth": smooth,
|
||||
"algorithm": "ridge_valley",
|
||||
"grade_class": options.get("grade_class", ROUTE_DEFAULT_GRADE_CLASS),
|
||||
"paved": bool(options.get("paved", False)),
|
||||
"max_uphill_grade_pct": round(max_uphill_grade * 100, 2),
|
||||
"max_downhill_grade_pct": round(max_downhill_grade * 100, 2),
|
||||
"min_uphill_grade_pct": round(min_uphill_grade * 100, 2),
|
||||
"min_downhill_grade_pct": round(min_downhill_grade * 100, 2),
|
||||
"grade_tolerance_pct": round(tol * 100, 2),
|
||||
"min_curve_radius_m": round(min_curve_radius_m, 2),
|
||||
"weights": options.get("weights") or {},
|
||||
"avoid_count": len(ap_list),
|
||||
"forbidden_count": len(fp_list),
|
||||
}
|
||||
|
||||
return {
|
||||
"polyline": polyline,
|
||||
"chainage_m": [round(v, 3) for v in chainage_m],
|
||||
"segments": segments,
|
||||
"required_point_checks": required_point_checks,
|
||||
"required_points_ok": required_points_ok,
|
||||
"avoid_intrusions": avoid_intrusions,
|
||||
"forbidden_intrusions": forbidden_intrusions,
|
||||
"curve_warning_segments": curve_warning_segments,
|
||||
"avoid_retry_performed": False,
|
||||
"conditions_snapshot": conditions_snapshot,
|
||||
"constant_grade_segments": constant_grade_segments,
|
||||
"metrics": {
|
||||
"length_m": round(length_m, 2),
|
||||
"avg_grade_pct": round(avg_grade_pct * 100, 2),
|
||||
"max_grade_pct": round(max_grade_pct * 100, 2),
|
||||
"max_uphill_pct": round(max_uphill_pct * 100, 2),
|
||||
"max_downhill_pct": round(max_downhill_pct * 100, 2),
|
||||
"slope_violations": slope_violations,
|
||||
"curve_violations": curve_violations,
|
||||
"min_curve_radius_m": round(min_curve_radius_actual, 2)
|
||||
if math.isfinite(min_curve_radius_actual)
|
||||
else None,
|
||||
"min_curve_radius_limit_m": round(min_curve_radius_m, 2),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
"""B05 지형 스켈레톤(주/지 능선·계곡) 추출.
|
||||
|
||||
수문학적 정의: D8 흐름누적이 임계값 이상인 셀=계곡, DEM 반전 시 능선.
|
||||
누적값 크기의 2차 임계값으로 주/지를 나눈다. whitebox 우선, 실패 시 numpy
|
||||
D8 폴백. 산출 polyline은 비용면과 동일 좌표계이며 B05_wf2_Route/route에 캐시된다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Solver import (
|
||||
_MODELS_SUBDIR,
|
||||
_ROUTE_CACHE_SUBDIR,
|
||||
_cost_surface_signature,
|
||||
_load_or_build_cost_surface,
|
||||
)
|
||||
from config.config_system import (
|
||||
SKELETON_MAIN_RIDGE_ACC_THRESHOLD_CELLS,
|
||||
SKELETON_MAIN_VALLEY_ACC_THRESHOLD_CELLS,
|
||||
SKELETON_RIDGE_ACC_THRESHOLD_CELLS,
|
||||
SKELETON_VALLEY_ACC_THRESHOLD_CELLS,
|
||||
)
|
||||
|
||||
SKELETON_CLASSES = ("main_ridge", "minor_ridge", "main_valley", "minor_valley")
|
||||
|
||||
_D8_OFFSETS = [
|
||||
(-1, -1),
|
||||
(-1, 0),
|
||||
(-1, 1),
|
||||
(0, -1),
|
||||
(0, 1),
|
||||
(1, -1),
|
||||
(1, 0),
|
||||
(1, 1),
|
||||
]
|
||||
|
||||
|
||||
def _default_thresholds() -> dict[str, float]:
|
||||
return {
|
||||
"valley_acc": float(SKELETON_VALLEY_ACC_THRESHOLD_CELLS),
|
||||
"main_valley_acc": float(SKELETON_MAIN_VALLEY_ACC_THRESHOLD_CELLS),
|
||||
"ridge_acc": float(SKELETON_RIDGE_ACC_THRESHOLD_CELLS),
|
||||
"main_ridge_acc": float(SKELETON_MAIN_RIDGE_ACC_THRESHOLD_CELLS),
|
||||
}
|
||||
|
||||
|
||||
def d8_flow_accumulation_numpy(z_grid: np.ndarray, valid_mask: np.ndarray) -> np.ndarray:
|
||||
"""numpy 기반 D8 흐름누적 (whitebox 폴백)."""
|
||||
rows, cols = z_grid.shape
|
||||
n = rows * cols
|
||||
z_flat = z_grid.ravel()
|
||||
valid_flat = valid_mask.ravel()
|
||||
|
||||
receiver = np.full(n, -1, dtype=np.int64)
|
||||
for idx in range(n):
|
||||
if not valid_flat[idx]:
|
||||
continue
|
||||
r, c = divmod(idx, cols)
|
||||
zc = z_flat[idx]
|
||||
best_slope = 0.0
|
||||
best = -1
|
||||
for dr, dc in _D8_OFFSETS:
|
||||
nr, nc = r + dr, c + dc
|
||||
if not (0 <= nr < rows and 0 <= nc < cols):
|
||||
continue
|
||||
nidx = nr * cols + nc
|
||||
if not valid_flat[nidx]:
|
||||
continue
|
||||
drop = zc - z_flat[nidx]
|
||||
if drop <= 0:
|
||||
continue
|
||||
dist = math.sqrt(2.0) if (dr != 0 and dc != 0) else 1.0
|
||||
slope = drop / dist
|
||||
if slope > best_slope:
|
||||
best_slope = slope
|
||||
best = nidx
|
||||
receiver[idx] = best
|
||||
|
||||
acc = np.where(valid_flat, 1.0, 0.0)
|
||||
order = np.argsort(-z_flat, kind="stable")
|
||||
for idx in order:
|
||||
recv = receiver[idx]
|
||||
if recv >= 0 and valid_flat[idx]:
|
||||
acc[recv] += acc[idx]
|
||||
return acc.reshape(rows, cols)
|
||||
|
||||
|
||||
def _d8_flow_accumulation_whitebox(
|
||||
z_grid: np.ndarray,
|
||||
x_coords: np.ndarray,
|
||||
y_coords: np.ndarray,
|
||||
valid_mask: np.ndarray,
|
||||
grid_res: float,
|
||||
) -> np.ndarray | None:
|
||||
"""WhiteboxTools로 D8 흐름누적을 계산한다 (실패 시 None → numpy 폴백)."""
|
||||
try:
|
||||
import rasterio
|
||||
from rasterio.transform import from_origin
|
||||
from whitebox import WhiteboxTools
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
nodata = -9999.0
|
||||
rows, cols = z_grid.shape
|
||||
z_out = np.where(valid_mask, z_grid, nodata).astype(np.float32)[::-1, :]
|
||||
transform = from_origin(
|
||||
float(x_coords[0]) - grid_res / 2.0,
|
||||
float(y_coords[-1]) + grid_res / 2.0,
|
||||
grid_res,
|
||||
grid_res,
|
||||
)
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="wbt_skel_") as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
dem_tif = tmp_path / "dem.tif"
|
||||
acc_tif = tmp_path / "acc.tif"
|
||||
with rasterio.open(
|
||||
dem_tif,
|
||||
"w",
|
||||
driver="GTiff",
|
||||
height=rows,
|
||||
width=cols,
|
||||
count=1,
|
||||
dtype="float32",
|
||||
nodata=nodata,
|
||||
transform=transform,
|
||||
) as dst:
|
||||
dst.write(z_out, 1)
|
||||
|
||||
wbt = WhiteboxTools()
|
||||
wbt.set_verbose_mode(False)
|
||||
wbt.set_working_dir(str(tmp_path))
|
||||
if wbt.fill_depressions("dem.tif", "filled.tif") != 0:
|
||||
raise RuntimeError("fill_depressions 실패")
|
||||
if wbt.d8_flow_accumulation("filled.tif", "acc.tif", out_type="cells") != 0:
|
||||
raise RuntimeError("d8_flow_accumulation 실패")
|
||||
|
||||
with rasterio.open(acc_tif) as src:
|
||||
acc = src.read(1).astype(np.float64)[::-1, :]
|
||||
return np.where(np.isfinite(acc) & (acc > 0) & valid_mask, acc, 0.0)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _flow_accumulation(
|
||||
z_grid: np.ndarray,
|
||||
x_coords: np.ndarray,
|
||||
y_coords: np.ndarray,
|
||||
valid_mask: np.ndarray,
|
||||
grid_res: float,
|
||||
) -> np.ndarray:
|
||||
acc = _d8_flow_accumulation_whitebox(z_grid, x_coords, y_coords, valid_mask, grid_res)
|
||||
if acc is None:
|
||||
acc = d8_flow_accumulation_numpy(z_grid, valid_mask)
|
||||
return acc
|
||||
|
||||
|
||||
def _trace_polylines(mask: np.ndarray) -> list[list[tuple[int, int]]]:
|
||||
"""1픽셀 폭 스켈레톤 마스크를 (r, c) polyline 목록으로 변환한다."""
|
||||
pixels = set(zip(*np.nonzero(mask)))
|
||||
if not pixels:
|
||||
return []
|
||||
|
||||
def neighbors(p):
|
||||
r, c = p
|
||||
return [(r + dr, c + dc) for dr, dc in _D8_OFFSETS if (r + dr, c + dc) in pixels]
|
||||
|
||||
degree = {p: len(neighbors(p)) for p in pixels}
|
||||
seeds = [p for p in pixels if degree[p] != 2]
|
||||
visited_edges: set = set()
|
||||
polylines: list[list[tuple[int, int]]] = []
|
||||
|
||||
def edge_key(a, b):
|
||||
return (a, b) if a <= b else (b, a)
|
||||
|
||||
def walk(start, nxt):
|
||||
path = [start, nxt]
|
||||
visited_edges.add(edge_key(start, nxt))
|
||||
prev, curr = start, nxt
|
||||
while degree[curr] == 2:
|
||||
candidates = [q for q in neighbors(curr) if q != prev]
|
||||
if not candidates:
|
||||
break
|
||||
q = candidates[0]
|
||||
if edge_key(curr, q) in visited_edges:
|
||||
break
|
||||
visited_edges.add(edge_key(curr, q))
|
||||
path.append(q)
|
||||
prev, curr = curr, q
|
||||
return path
|
||||
|
||||
for seed in seeds:
|
||||
for nb in neighbors(seed):
|
||||
if edge_key(seed, nb) not in visited_edges:
|
||||
polylines.append(walk(seed, nb))
|
||||
|
||||
for p in pixels:
|
||||
if degree[p] == 2:
|
||||
for nb in neighbors(p):
|
||||
if edge_key(p, nb) not in visited_edges:
|
||||
polylines.append(walk(p, nb))
|
||||
|
||||
return [pl for pl in polylines if len(pl) >= 2]
|
||||
|
||||
|
||||
def _mask_to_polylines(
|
||||
mask: np.ndarray, x_coords: np.ndarray, y_coords: np.ndarray, z_grid: np.ndarray
|
||||
) -> list[dict[str, Any]]:
|
||||
"""셀 마스크를 세선화한 뒤 모델좌표 polyline 목록으로 변환한다."""
|
||||
if not mask.any():
|
||||
return []
|
||||
try:
|
||||
from skimage.morphology import skeletonize
|
||||
|
||||
skel = skeletonize(mask)
|
||||
except Exception:
|
||||
skel = mask
|
||||
out = []
|
||||
for pixel_path in _trace_polylines(skel):
|
||||
poly = [
|
||||
[float(x_coords[c]), float(y_coords[r]), float(z_grid[r, c])] for r, c in pixel_path
|
||||
]
|
||||
out.append({"polyline": poly})
|
||||
return out
|
||||
|
||||
|
||||
def extract_skeleton_from_grid(
|
||||
x_coords: np.ndarray,
|
||||
y_coords: np.ndarray,
|
||||
z_grid: np.ndarray,
|
||||
valid_mask: np.ndarray,
|
||||
grid_res: float,
|
||||
thresholds: dict[str, float] | None = None,
|
||||
use_whitebox: bool = True,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""격자에서 주/지 능선·계곡 polyline을 추출한다 (캐시 없음, 테스트용 공개 API)."""
|
||||
th = thresholds or _default_thresholds()
|
||||
|
||||
if use_whitebox:
|
||||
acc_valley = _flow_accumulation(z_grid, x_coords, y_coords, valid_mask, grid_res)
|
||||
acc_ridge = _flow_accumulation(-z_grid, x_coords, y_coords, valid_mask, grid_res)
|
||||
else:
|
||||
acc_valley = d8_flow_accumulation_numpy(z_grid, valid_mask)
|
||||
acc_ridge = d8_flow_accumulation_numpy(-z_grid, valid_mask)
|
||||
|
||||
valley_mask = valid_mask & (acc_valley >= th["valley_acc"])
|
||||
main_valley_mask = valley_mask & (acc_valley >= th["main_valley_acc"])
|
||||
minor_valley_mask = valley_mask & ~main_valley_mask
|
||||
|
||||
ridge_mask = valid_mask & ~valley_mask & (acc_ridge >= th["ridge_acc"])
|
||||
main_ridge_mask = ridge_mask & (acc_ridge >= th["main_ridge_acc"])
|
||||
minor_ridge_mask = ridge_mask & ~main_ridge_mask
|
||||
|
||||
return {
|
||||
"main_ridge": _mask_to_polylines(main_ridge_mask, x_coords, y_coords, z_grid),
|
||||
"minor_ridge": _mask_to_polylines(minor_ridge_mask, x_coords, y_coords, z_grid),
|
||||
"main_valley": _mask_to_polylines(main_valley_mask, x_coords, y_coords, z_grid),
|
||||
"minor_valley": _mask_to_polylines(minor_valley_mask, x_coords, y_coords, z_grid),
|
||||
}
|
||||
|
||||
|
||||
def _skeleton_signature(models_dir: Path, filter_key: str, method: str, smooth: bool) -> str:
|
||||
"""소스 모델 + 격자 해상도 + 분류 임계값 서명."""
|
||||
th = _default_thresholds()
|
||||
th_part = "|".join(f"{k}={v}" for k, v in sorted(th.items()))
|
||||
return _cost_surface_signature(models_dir, filter_key, method, smooth) + "|" + th_part
|
||||
|
||||
|
||||
def load_or_build_skeleton(
|
||||
project_root: Path, filter_key: str, method: str, smooth: bool
|
||||
) -> dict[str, Any]:
|
||||
"""스켈레톤을 캐시에서 로드하거나 새로 계산해 저장한다."""
|
||||
project_root = Path(project_root)
|
||||
models_dir = project_root / _MODELS_SUBDIR
|
||||
cache_dir = project_root / _ROUTE_CACHE_SUBDIR
|
||||
suffix = "_smooth" if smooth else ""
|
||||
cache_path = cache_dir / f"terrain_skeleton_{filter_key}_{method}{suffix}.json"
|
||||
signature = _skeleton_signature(models_dir, filter_key, method, smooth)
|
||||
|
||||
if cache_path.exists():
|
||||
try:
|
||||
with open(cache_path, encoding="utf-8") as f:
|
||||
cached = json.load(f)
|
||||
if cached.get("signature") == signature:
|
||||
return cached
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_t0 = time.time()
|
||||
(x_coords, y_coords, z_grid, valid_mask, _dz_dx, _dz_dy, grid_res) = (
|
||||
_load_or_build_cost_surface(project_root, models_dir, filter_key, method, smooth)
|
||||
)
|
||||
z_grid = np.asarray(z_grid, dtype=np.float64)
|
||||
valid_mask = np.asarray(valid_mask, dtype=bool)
|
||||
|
||||
skeleton = extract_skeleton_from_grid(
|
||||
np.asarray(x_coords), np.asarray(y_coords), z_grid, valid_mask, float(grid_res)
|
||||
)
|
||||
result: dict[str, Any] = dict(skeleton)
|
||||
result["grid_res"] = float(grid_res)
|
||||
result["signature"] = signature
|
||||
|
||||
try:
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(cache_path, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False)
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
@@ -0,0 +1,656 @@
|
||||
"""B05 최적 경로 탐색 오케스트레이터.
|
||||
|
||||
확정된 지표면 모델(B04_wf1_Surface/models)의 표고를 DTM 격자에 샘플링해
|
||||
비용면을 만들고(캐시 재사용), BP→CP…→EP 다구간 Dijkstra로 최적 경로를
|
||||
계산한 뒤 평활·측점·곡률·제약검증 메트릭을 반환한다.
|
||||
"""
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Geometry import (
|
||||
circle_intrusions,
|
||||
circumradius_2d,
|
||||
compute_gradients,
|
||||
point_to_polyline_dist_2d,
|
||||
resample_polyline_2d,
|
||||
single_segment_dijkstra,
|
||||
)
|
||||
from config.config_system import (
|
||||
FOREST_ROAD_MAX_GRADE,
|
||||
FOREST_ROAD_MIN_CURVE_R_M,
|
||||
ROUTE_DEFAULT_GRADE_CLASS,
|
||||
ROUTE_GRID_RES_M,
|
||||
ROUTE_MAX_COST_CELLS,
|
||||
ROUTE_MAX_GRADE,
|
||||
ROUTE_MAX_GRADE_PAVED,
|
||||
ROUTE_REQUIRED_POINT_TOLERANCE_M,
|
||||
ROUTE_W_AVOID,
|
||||
ROUTE_W_CURVE,
|
||||
ROUTE_W_DIST,
|
||||
ROUTE_W_GRADE,
|
||||
ROUTE_W_SIDE,
|
||||
ROUTE_WEIGHT_MAX,
|
||||
)
|
||||
|
||||
# B04 지표면 모델 폴더명 (비용면의 표고 원본)
|
||||
_MODELS_SUBDIR = Path("B04_wf1_Surface") / "models"
|
||||
# B05 비용면 캐시 폴더명
|
||||
_ROUTE_CACHE_SUBDIR = Path("B05_wf2_Route") / "route"
|
||||
|
||||
|
||||
def _load_dtm_grid(models_dir: Path, filter_key: str, smooth: bool):
|
||||
"""필터의 정규 DTM 격자(x, y, z, valid_mask)를 로드한다."""
|
||||
suffix = "_smooth" if smooth else ""
|
||||
dtm_path = models_dir / f"dtm_{filter_key}{suffix}.npz"
|
||||
if not dtm_path.exists():
|
||||
dtm_path = models_dir / f"dtm_{filter_key}.npz"
|
||||
if not dtm_path.exists():
|
||||
raise FileNotFoundError(f"DTM 파일을 찾을 수 없습니다: dtm_{filter_key}{suffix}.npz")
|
||||
d = np.load(dtm_path)
|
||||
return (
|
||||
np.asarray(d["x"]),
|
||||
np.asarray(d["y"]),
|
||||
np.asarray(d["z"], dtype=np.float64),
|
||||
np.asarray(d["valid_mask"]),
|
||||
)
|
||||
|
||||
|
||||
def _sample_surface_on_grid(
|
||||
models_dir: Path,
|
||||
filter_key: str,
|
||||
method: str,
|
||||
smooth: bool,
|
||||
x_coords: np.ndarray,
|
||||
y_coords: np.ndarray,
|
||||
dtm_z: np.ndarray,
|
||||
) -> np.ndarray:
|
||||
"""확정 지표면 모델의 표고를 DTM 격자에 샘플링한다(실패 시 DTM으로 폴백)."""
|
||||
if method == "dtm":
|
||||
return dtm_z
|
||||
|
||||
models_dir = Path(models_dir)
|
||||
xx, yy = np.meshgrid(x_coords, y_coords)
|
||||
query = np.column_stack([xx.ravel(), yy.ravel()])
|
||||
|
||||
def _finalize(z_flat: np.ndarray) -> np.ndarray:
|
||||
z = np.asarray(z_flat, dtype=np.float64).reshape(len(y_coords), len(x_coords))
|
||||
bad = ~np.isfinite(z)
|
||||
if bad.any():
|
||||
z[bad] = dtm_z[bad]
|
||||
return z
|
||||
|
||||
try:
|
||||
suffix = "_smooth" if smooth else ""
|
||||
if method == "tin":
|
||||
from scipy.interpolate import LinearNDInterpolator
|
||||
|
||||
path = models_dir / f"tin_{filter_key}{suffix}.npz"
|
||||
if not path.exists():
|
||||
path = models_dir / f"tin_{filter_key}.npz"
|
||||
d = np.load(path)
|
||||
verts = np.asarray(d["vertices"], dtype=np.float64)
|
||||
interp = LinearNDInterpolator(verts[:, :2], verts[:, 2])
|
||||
return _finalize(interp(query))
|
||||
|
||||
if method == "nurbs":
|
||||
from scipy.interpolate import RectBivariateSpline
|
||||
|
||||
d = np.load(models_dir / f"nurbs_{filter_key}.npz")
|
||||
cx = np.asarray(d["control_x"], dtype=np.float64)
|
||||
cy = np.asarray(d["control_y"], dtype=np.float64)
|
||||
cz = np.asarray(d["control_z"], dtype=np.float64)
|
||||
degree = int(d["degree"][0]) if "degree" in d else 3
|
||||
spline = RectBivariateSpline(
|
||||
cy, cx, cz, kx=min(degree, len(cy) - 1), ky=min(degree, len(cx) - 1)
|
||||
)
|
||||
return _finalize(spline(y_coords, x_coords).ravel())
|
||||
|
||||
if method == "implicit":
|
||||
from scipy.interpolate import RBFInterpolator
|
||||
|
||||
d = np.load(models_dir / f"implicit_{filter_key}.npz")
|
||||
centers = np.asarray(d["centers_xy"], dtype=np.float64)
|
||||
cz = np.asarray(d["center_z"], dtype=np.float64)
|
||||
smoothing = float(d["smoothing"][0]) if "smoothing" in d else 0.0
|
||||
interp = RBFInterpolator(
|
||||
centers,
|
||||
cz,
|
||||
neighbors=min(64, len(centers)),
|
||||
smoothing=smoothing,
|
||||
kernel="thin_plate_spline",
|
||||
)
|
||||
out = np.empty(len(query), dtype=np.float64)
|
||||
for s in range(0, len(query), 50_000):
|
||||
e = min(s + 50_000, len(query))
|
||||
out[s:e] = interp(query[s:e])
|
||||
return _finalize(out)
|
||||
|
||||
if method == "meshfree":
|
||||
from scipy.interpolate import griddata
|
||||
|
||||
d = np.load(models_dir / f"meshfree_{filter_key}.npz")
|
||||
pts = np.asarray(d["points"], dtype=np.float64)
|
||||
z = griddata(pts[:, :2], pts[:, 2], query, method="linear")
|
||||
return _finalize(z)
|
||||
except Exception:
|
||||
return dtm_z
|
||||
|
||||
return dtm_z
|
||||
|
||||
|
||||
def _source_npz_paths(models_dir: Path, filter_key: str, method: str, smooth: bool) -> list[Path]:
|
||||
"""비용면이 의존하는 소스 모델 파일(존재하는 것만, 안정 순서)."""
|
||||
suffix = "_smooth" if smooth else ""
|
||||
candidates = [
|
||||
models_dir / f"dtm_{filter_key}{suffix}.npz",
|
||||
models_dir / f"dtm_{filter_key}.npz",
|
||||
]
|
||||
if method != "dtm":
|
||||
candidates.append(models_dir / f"{method}_{filter_key}{suffix}.npz")
|
||||
candidates.append(models_dir / f"{method}_{filter_key}.npz")
|
||||
seen: set[Path] = set()
|
||||
out: list[Path] = []
|
||||
for p in candidates:
|
||||
if p.exists() and p not in seen:
|
||||
seen.add(p)
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def _cost_surface_signature(models_dir: Path, filter_key: str, method: str, smooth: bool) -> str:
|
||||
"""비용면 재빌드 필요 시 바뀌는 서명(격자해상도 + 소스파일 mtime/size)."""
|
||||
parts = [f"res={ROUTE_GRID_RES_M}", f"method={method}", f"smooth={smooth}"]
|
||||
for p in _source_npz_paths(models_dir, filter_key, method, smooth):
|
||||
st = p.stat()
|
||||
parts.append(f"{p.name}:{int(st.st_mtime)}:{st.st_size}")
|
||||
return "|".join(parts)
|
||||
|
||||
|
||||
def _build_cost_surface(models_dir: Path, filter_key: str, method: str, smooth: bool):
|
||||
"""다운샘플된 비용면(좌표·표고·footprint·기울기)을 만든다."""
|
||||
x_full, y_full, dtm_z_full, valid_full = _load_dtm_grid(models_dir, filter_key, smooth)
|
||||
|
||||
target_res = ROUTE_GRID_RES_M
|
||||
src_res = (x_full[-1] - x_full[0]) / (len(x_full) - 1)
|
||||
step = max(1, int(round(target_res / src_res)))
|
||||
x_sub = np.ascontiguousarray(x_full[::step])
|
||||
y_sub = np.ascontiguousarray(y_full[::step])
|
||||
|
||||
n_cells = len(x_sub) * len(y_sub)
|
||||
if n_cells > ROUTE_MAX_COST_CELLS:
|
||||
raise ValueError(
|
||||
f"경로 비용면 격자 셀 수({n_cells:,})가 한도({ROUTE_MAX_COST_CELLS:,})를 "
|
||||
f"초과합니다. ROUTE_GRID_RES_M({target_res} m)를 키우거나 영역을 줄이세요."
|
||||
)
|
||||
|
||||
dtm_z_sub = np.array(dtm_z_full[::step, ::step], dtype=np.float64)
|
||||
valid_sub = np.array(valid_full[::step, ::step])
|
||||
del dtm_z_full, valid_full
|
||||
|
||||
z_sub = _sample_surface_on_grid(models_dir, filter_key, method, smooth, x_sub, y_sub, dtm_z_sub)
|
||||
z_sub = np.asarray(z_sub, dtype=np.float64)
|
||||
if not np.all(np.isfinite(z_sub)):
|
||||
finite = z_sub[np.isfinite(z_sub)]
|
||||
fill = float(finite.mean()) if finite.size else 0.0
|
||||
z_sub = np.where(np.isfinite(z_sub), z_sub, fill)
|
||||
|
||||
res_y = (y_sub[-1] - y_sub[0]) / (len(y_sub) - 1) if len(y_sub) > 1 else target_res
|
||||
res_x = (x_sub[-1] - x_sub[0]) / (len(x_sub) - 1) if len(x_sub) > 1 else target_res
|
||||
dz_dx, dz_dy = compute_gradients(z_sub, res_y, res_x)
|
||||
|
||||
grid_res = float(0.5 * (res_x + res_y))
|
||||
return x_sub, y_sub, z_sub, valid_sub, dz_dx, dz_dy, grid_res
|
||||
|
||||
|
||||
def _load_or_build_cost_surface(
|
||||
project_root: Path, models_dir: Path, filter_key: str, method: str, smooth: bool
|
||||
):
|
||||
"""비용면을 반환한다(서명 일치 시 캐시 재사용, 아니면 재빌드·재캐시)."""
|
||||
cache_dir = project_root / _ROUTE_CACHE_SUBDIR
|
||||
suffix = "_smooth" if smooth else ""
|
||||
cache_path = cache_dir / f"cost_surface_{filter_key}_{method}{suffix}.npz"
|
||||
signature = _cost_surface_signature(models_dir, filter_key, method, smooth)
|
||||
|
||||
if cache_path.exists():
|
||||
try:
|
||||
cached = np.load(cache_path, allow_pickle=False)
|
||||
if str(cached["signature"]) == signature:
|
||||
return (
|
||||
cached["x"],
|
||||
cached["y"],
|
||||
cached["z"],
|
||||
cached["valid_mask"],
|
||||
cached["dz_dx"],
|
||||
cached["dz_dy"],
|
||||
float(cached["target_res"][0]),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
surface = _build_cost_surface(models_dir, filter_key, method, smooth)
|
||||
x_sub, y_sub, z_sub, valid_sub, dz_dx, dz_dy, target_res = surface
|
||||
try:
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
np.savez_compressed(
|
||||
cache_path,
|
||||
x=x_sub,
|
||||
y=y_sub,
|
||||
z=z_sub,
|
||||
valid_mask=valid_sub,
|
||||
dz_dx=dz_dx,
|
||||
dz_dy=dz_dy,
|
||||
target_res=np.array([target_res], np.float64),
|
||||
signature=np.array(signature),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return surface
|
||||
|
||||
|
||||
def _empty_route_result() -> dict[str, Any]:
|
||||
return {
|
||||
"polyline": [],
|
||||
"chainage_m": [],
|
||||
"segments": [],
|
||||
"required_point_checks": [],
|
||||
"required_points_ok": False,
|
||||
"avoid_intrusions": [],
|
||||
"forbidden_intrusions": [],
|
||||
"curve_warning_segments": [],
|
||||
"avoid_retry_performed": False,
|
||||
"conditions_snapshot": {},
|
||||
"metrics": {
|
||||
"length_m": 0.0,
|
||||
"avg_grade_pct": 0.0,
|
||||
"max_grade_pct": 0.0,
|
||||
"slope_violations": 0,
|
||||
"curve_violations": 0,
|
||||
"min_curve_radius_m": None,
|
||||
"min_curve_radius_limit_m": 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def solve_optimal_route(
|
||||
project_root: Path,
|
||||
filter_key: str,
|
||||
smooth: bool,
|
||||
points_data: dict[str, Any],
|
||||
options: dict[str, Any],
|
||||
method: str = "dtm",
|
||||
_avoid_retry: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""다구간 비용면 경로 탐색을 오케스트레이션해 최종 좌표·메트릭을 반환한다."""
|
||||
project_root = Path(project_root)
|
||||
models_dir = project_root / _MODELS_SUBDIR
|
||||
|
||||
(
|
||||
x_coords_sub,
|
||||
y_coords_sub,
|
||||
z_grid_sub,
|
||||
valid_mask_sub,
|
||||
dz_dx,
|
||||
dz_dy,
|
||||
target_res,
|
||||
) = _load_or_build_cost_surface(project_root, models_dir, filter_key, method, smooth)
|
||||
|
||||
bp = points_data.get("bp")
|
||||
ep = points_data.get("ep")
|
||||
cp_list = sorted(points_data.get("cp", []), key=lambda x: x.get("order", 0))
|
||||
ap_list = points_data.get("ap", [])
|
||||
fp_list = points_data.get("fp", [])
|
||||
|
||||
if fp_list:
|
||||
valid_mask_sub = np.array(valid_mask_sub, copy=True)
|
||||
xx, yy = np.meshgrid(x_coords_sub, y_coords_sub)
|
||||
for fp in fp_list:
|
||||
inside = (xx - fp["x"]) ** 2 + (yy - fp["y"]) ** 2 < float(fp["radius_m"]) ** 2
|
||||
valid_mask_sub[inside] = False
|
||||
|
||||
if not bp or not ep:
|
||||
return _empty_route_result()
|
||||
|
||||
sequence = [bp] + cp_list + [ep]
|
||||
|
||||
def _nearest_coord_index(coords: np.ndarray, value: float) -> int:
|
||||
upper = int(np.clip(np.searchsorted(coords, value), 0, len(coords) - 1))
|
||||
lower = max(upper - 1, 0)
|
||||
return lower if abs(value - coords[lower]) <= abs(coords[upper] - value) else upper
|
||||
|
||||
def get_grid_indices(pt: dict[str, float]) -> tuple[int, int, bool]:
|
||||
c = _nearest_coord_index(x_coords_sub, pt["x"])
|
||||
r = _nearest_coord_index(y_coords_sub, pt["y"])
|
||||
in_bounds = float(x_coords_sub[0]) <= pt["x"] <= float(x_coords_sub[-1]) and float(
|
||||
y_coords_sub[0]
|
||||
) <= pt["y"] <= float(y_coords_sub[-1])
|
||||
point_on_valid_terrain = in_bounds and bool(valid_mask_sub[r, c])
|
||||
if not point_on_valid_terrain:
|
||||
valid_ys, valid_xs = np.where(valid_mask_sub)
|
||||
if len(valid_ys) > 0:
|
||||
dists = (valid_ys - r) ** 2 + (valid_xs - c) ** 2
|
||||
best_idx = np.argmin(dists)
|
||||
r, c = int(valid_ys[best_idx]), int(valid_xs[best_idx])
|
||||
return r, c, point_on_valid_terrain
|
||||
|
||||
tol_req = ROUTE_REQUIRED_POINT_TOLERANCE_M
|
||||
required_snap = []
|
||||
for pt in sequence:
|
||||
r, c, point_on_valid_terrain = get_grid_indices(pt)
|
||||
sx, sy = float(x_coords_sub[c]), float(y_coords_sub[r])
|
||||
snap_dist = math.hypot(pt["x"] - sx, pt["y"] - sy)
|
||||
required_snap.append(
|
||||
{
|
||||
"r": r,
|
||||
"c": c,
|
||||
"snap_x": sx,
|
||||
"snap_y": sy,
|
||||
"snap_dist": snap_dist,
|
||||
"point_on_valid_terrain": point_on_valid_terrain,
|
||||
}
|
||||
)
|
||||
|
||||
weights = options.get("weights") or {
|
||||
"dist": ROUTE_W_DIST,
|
||||
"grade": ROUTE_W_GRADE,
|
||||
"side": ROUTE_W_SIDE,
|
||||
"curve": ROUTE_W_CURVE,
|
||||
"avoid": ROUTE_W_AVOID,
|
||||
}
|
||||
paved = options.get("paved", False)
|
||||
|
||||
grade_class = options.get("grade_class", ROUTE_DEFAULT_GRADE_CLASS)
|
||||
base_max_grade = FOREST_ROAD_MAX_GRADE.get(grade_class, ROUTE_MAX_GRADE)
|
||||
max_grade = max(base_max_grade, ROUTE_MAX_GRADE_PAVED) if paved else base_max_grade
|
||||
|
||||
min_curve_radius_m = options.get("min_curve_radius_m")
|
||||
if not min_curve_radius_m or min_curve_radius_m <= 0:
|
||||
min_curve_radius_m = FOREST_ROAD_MIN_CURVE_R_M.get(grade_class, 12.0)
|
||||
|
||||
def _grade_opt(key: str) -> float:
|
||||
v = options.get(key)
|
||||
return float(v) if (v is not None and float(v) > 0) else max_grade
|
||||
|
||||
max_uphill_grade = _grade_opt("max_uphill_grade")
|
||||
max_downhill_grade = _grade_opt("max_downhill_grade")
|
||||
|
||||
def _point_label(idx: int, pt: dict[str, Any]) -> str:
|
||||
if idx == 0:
|
||||
return "BP"
|
||||
if idx == len(sequence) - 1:
|
||||
return "EP"
|
||||
return f"CP{pt.get('order', idx)}"
|
||||
|
||||
full_path_grid: list[tuple[int, int]] = []
|
||||
segment_bounds: list[dict[str, Any]] = []
|
||||
|
||||
for i in range(len(sequence) - 1):
|
||||
pt_start = sequence[i]
|
||||
pt_end = sequence[i + 1]
|
||||
r_s, c_s, _ = get_grid_indices(pt_start)
|
||||
r_e, c_e, _ = get_grid_indices(pt_end)
|
||||
|
||||
segment = single_segment_dijkstra(
|
||||
r_s,
|
||||
c_s,
|
||||
r_e,
|
||||
c_e,
|
||||
x_coords_sub,
|
||||
y_coords_sub,
|
||||
z_grid_sub,
|
||||
valid_mask_sub,
|
||||
dz_dx,
|
||||
dz_dy,
|
||||
ap_list,
|
||||
weights,
|
||||
max_grade,
|
||||
target_res,
|
||||
min_curve_radius_m,
|
||||
max_uphill_grade,
|
||||
max_downhill_grade,
|
||||
)
|
||||
if not segment:
|
||||
fp_note = "·금지구역(FP)" if fp_list else ""
|
||||
raise ValueError(
|
||||
f"세그먼트 {i + 1} ({_point_label(i, pt_start)} -> {_point_label(i + 1, pt_end)}) "
|
||||
f"경로 탐색 실패: 종단경사 한계({max_grade * 100:.0f}%)·최소곡선반지름"
|
||||
f"({min_curve_radius_m:.0f}m)·회피지역{fp_note} 제약으로 통과 경로가 없습니다."
|
||||
)
|
||||
|
||||
start_idx = max(len(full_path_grid) - 1, 0)
|
||||
if i > 0 and len(segment) > 0:
|
||||
full_path_grid.extend(segment[1:])
|
||||
else:
|
||||
full_path_grid.extend(segment)
|
||||
segment_bounds.append(
|
||||
{
|
||||
"index": i,
|
||||
"from": _point_label(i, pt_start),
|
||||
"to": _point_label(i + 1, pt_end),
|
||||
"point_start": start_idx,
|
||||
"point_end": len(full_path_grid) - 1,
|
||||
}
|
||||
)
|
||||
|
||||
polyline = []
|
||||
for r, c in full_path_grid:
|
||||
polyline.append([float(x_coords_sub[c]), float(y_coords_sub[r]), float(z_grid_sub[r, c])])
|
||||
|
||||
def _grid_z(px: float, py: float) -> float:
|
||||
c_idx = _nearest_coord_index(x_coords_sub, px)
|
||||
r_idx = _nearest_coord_index(y_coords_sub, py)
|
||||
return float(z_grid_sub[r_idx, c_idx])
|
||||
|
||||
def _pin_coord_for(seq_idx: int) -> list[float]:
|
||||
snap = required_snap[seq_idx]
|
||||
pt = sequence[seq_idx]
|
||||
if snap["point_on_valid_terrain"]:
|
||||
return [pt["x"], pt["y"], _grid_z(pt["x"], pt["y"])]
|
||||
return [snap["snap_x"], snap["snap_y"], _grid_z(snap["snap_x"], snap["snap_y"])]
|
||||
|
||||
pin_coords: dict[int, list[float]] = {}
|
||||
for sb in segment_bounds:
|
||||
pin_coords[sb["point_start"]] = _pin_coord_for(sb["index"])
|
||||
pin_coords[sb["point_end"]] = _pin_coord_for(sb["index"] + 1)
|
||||
|
||||
if len(polyline) > 4:
|
||||
original = polyline
|
||||
smoothed_polyline = []
|
||||
window_size = 3
|
||||
padded = [original[0]] * (window_size // 2) + original + [original[-1]] * (window_size // 2)
|
||||
for i in range(len(original)):
|
||||
if i in pin_coords:
|
||||
smoothed_polyline.append(list(pin_coords[i]))
|
||||
continue
|
||||
window = padded[i : i + window_size]
|
||||
sx = sum(p[0] for p in window) / window_size
|
||||
sy = sum(p[1] for p in window) / window_size
|
||||
sz = sum(p[2] for p in window) / window_size
|
||||
smoothed_polyline.append([sx, sy, sz])
|
||||
polyline = smoothed_polyline
|
||||
else:
|
||||
for i, coord in pin_coords.items():
|
||||
if 0 <= i < len(polyline):
|
||||
polyline[i] = list(coord)
|
||||
|
||||
n = len(polyline)
|
||||
chainage_m = [0.0] * n
|
||||
length_m = 0.0
|
||||
slope_violations = 0
|
||||
max_grade_pct = 0.0
|
||||
grade_sums = 0.0
|
||||
max_uphill_pct = 0.0
|
||||
max_downhill_pct = 0.0
|
||||
for i in range(n - 1):
|
||||
x1, y1, z1 = polyline[i]
|
||||
x2, y2, z2 = polyline[i + 1]
|
||||
h_dist = math.hypot(x2 - x1, y2 - y1)
|
||||
chainage_m[i + 1] = chainage_m[i] + h_dist
|
||||
if h_dist > 0.01:
|
||||
dz = z2 - z1
|
||||
segment_slope = abs(dz) / h_dist
|
||||
length_m += h_dist
|
||||
grade_sums += segment_slope * h_dist
|
||||
max_grade_pct = max(max_grade_pct, segment_slope)
|
||||
applicable = max_uphill_grade if dz > 0 else max_downhill_grade
|
||||
if dz > 0:
|
||||
max_uphill_pct = max(max_uphill_pct, segment_slope)
|
||||
else:
|
||||
max_downhill_pct = max(max_downhill_pct, segment_slope)
|
||||
if segment_slope > applicable:
|
||||
slope_violations += 1
|
||||
|
||||
avg_grade_pct = (grade_sums / length_m) if length_m > 0 else 0.0
|
||||
|
||||
curve_check_step = max(2.0 * target_res, 4.0)
|
||||
resampled = resample_polyline_2d(polyline, chainage_m, curve_check_step)
|
||||
total_chainage = chainage_m[-1] if chainage_m else 0.0
|
||||
|
||||
def _poly_index_at_chainage(ch: float) -> int:
|
||||
idx = int(np.searchsorted(chainage_m, ch)) if chainage_m else 0
|
||||
return int(min(max(idx, 0), max(len(polyline) - 1, 0)))
|
||||
|
||||
curve_violations = 0
|
||||
min_curve_radius_actual = float("inf")
|
||||
radii = [float("inf")] * len(resampled)
|
||||
for i in range(1, len(resampled) - 1):
|
||||
radius = circumradius_2d(resampled[i - 1], resampled[i], resampled[i + 1])
|
||||
radii[i] = radius
|
||||
min_curve_radius_actual = min(min_curve_radius_actual, radius)
|
||||
if radius < min_curve_radius_m:
|
||||
curve_violations += 1
|
||||
|
||||
curve_warning_segments = []
|
||||
run_start = None
|
||||
for i in range(1, len(resampled)):
|
||||
violating = i < len(resampled) - 1 and radii[i] < min_curve_radius_m
|
||||
if violating and run_start is None:
|
||||
run_start = i
|
||||
if (not violating) and run_start is not None:
|
||||
run_end = i - 1
|
||||
ch_s = min(run_start * curve_check_step, total_chainage)
|
||||
ch_e = min(run_end * curve_check_step, total_chainage)
|
||||
curve_warning_segments.append(
|
||||
{
|
||||
"chainage_start_m": round(ch_s, 2),
|
||||
"chainage_end_m": round(ch_e, 2),
|
||||
"min_radius_m": round(min(radii[run_start : run_end + 1]), 2),
|
||||
"required_radius_m": round(min_curve_radius_m, 2),
|
||||
"polyline_start_index": _poly_index_at_chainage(ch_s),
|
||||
"polyline_end_index": _poly_index_at_chainage(ch_e),
|
||||
}
|
||||
)
|
||||
run_start = None
|
||||
|
||||
segments = []
|
||||
for sb in segment_bounds:
|
||||
s, e = sb["point_start"], min(sb["point_end"], n - 1)
|
||||
seg_max_grade = 0.0
|
||||
for i in range(s, e):
|
||||
x1, y1, z1 = polyline[i]
|
||||
x2, y2, z2 = polyline[i + 1]
|
||||
hd = math.hypot(x2 - x1, y2 - y1)
|
||||
if hd > 0.01:
|
||||
seg_max_grade = max(seg_max_grade, abs(z2 - z1) / hd)
|
||||
segments.append(
|
||||
{
|
||||
"index": sb["index"],
|
||||
"from": sb["from"],
|
||||
"to": sb["to"],
|
||||
"point_start": s,
|
||||
"point_end": e,
|
||||
"chainage_start_m": round(chainage_m[s], 2),
|
||||
"chainage_end_m": round(chainage_m[e], 2),
|
||||
"length_m": round(chainage_m[e] - chainage_m[s], 2),
|
||||
"max_grade_pct": round(seg_max_grade * 100, 2),
|
||||
}
|
||||
)
|
||||
|
||||
required_point_checks = []
|
||||
for idx, pt in enumerate(sequence):
|
||||
label = _point_label(idx, pt)
|
||||
poly_dist = point_to_polyline_dist_2d(pt["x"], pt["y"], polyline)
|
||||
snap = required_snap[idx]
|
||||
snap_dist = snap["snap_dist"]
|
||||
dist_val = poly_dist if snap["point_on_valid_terrain"] else max(poly_dist, snap_dist)
|
||||
required_point_checks.append(
|
||||
{
|
||||
"point": label,
|
||||
"x": pt["x"],
|
||||
"y": pt["y"],
|
||||
"distance_m": round(dist_val, 3),
|
||||
"snap_distance_m": round(snap_dist, 3),
|
||||
"point_on_valid_terrain": snap["point_on_valid_terrain"],
|
||||
"tolerance_m": tol_req,
|
||||
"within_tolerance": bool(dist_val <= tol_req),
|
||||
}
|
||||
)
|
||||
required_points_ok = all(c["within_tolerance"] for c in required_point_checks)
|
||||
|
||||
avoid_intrusions = circle_intrusions(polyline, ap_list, lambda i: f"AP{i + 1}")
|
||||
forbidden_intrusions = circle_intrusions(polyline, fp_list, lambda i: f"FP{i + 1}")
|
||||
allow_pass = bool(options.get("allow_avoid_pass_through", False))
|
||||
any_intrusion = any(a["intrudes"] for a in avoid_intrusions)
|
||||
if any_intrusion and not allow_pass and not _avoid_retry:
|
||||
boosted = dict(options)
|
||||
bw = dict(weights)
|
||||
bw["avoid"] = min(bw.get("avoid", ROUTE_W_AVOID) * 10.0, ROUTE_WEIGHT_MAX)
|
||||
boosted["weights"] = bw
|
||||
try:
|
||||
retried = solve_optimal_route(
|
||||
project_root,
|
||||
filter_key,
|
||||
smooth,
|
||||
points_data,
|
||||
boosted,
|
||||
method=method,
|
||||
_avoid_retry=True,
|
||||
)
|
||||
retried["avoid_retry_performed"] = True
|
||||
return retried
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
conditions_snapshot = {
|
||||
"filter": filter_key,
|
||||
"method": method,
|
||||
"smooth": smooth,
|
||||
"grade_class": grade_class,
|
||||
"paved": paved,
|
||||
"max_grade_pct": round(max_grade * 100, 2),
|
||||
"max_uphill_grade_pct": round(max_uphill_grade * 100, 2),
|
||||
"max_downhill_grade_pct": round(max_downhill_grade * 100, 2),
|
||||
"min_curve_radius_m": round(min_curve_radius_m, 2),
|
||||
"weights": weights,
|
||||
"avoid_count": len(ap_list),
|
||||
"forbidden_count": len(fp_list),
|
||||
}
|
||||
|
||||
return {
|
||||
"polyline": polyline,
|
||||
"chainage_m": [round(v, 3) for v in chainage_m],
|
||||
"segments": segments,
|
||||
"required_point_checks": required_point_checks,
|
||||
"required_points_ok": required_points_ok,
|
||||
"avoid_intrusions": avoid_intrusions,
|
||||
"forbidden_intrusions": forbidden_intrusions,
|
||||
"curve_warning_segments": curve_warning_segments,
|
||||
"avoid_retry_performed": _avoid_retry,
|
||||
"conditions_snapshot": conditions_snapshot,
|
||||
"metrics": {
|
||||
"length_m": round(length_m, 2),
|
||||
"avg_grade_pct": round(avg_grade_pct * 100, 2),
|
||||
"max_grade_pct": round(max_grade_pct * 100, 2),
|
||||
"max_uphill_pct": round(max_uphill_pct * 100, 2),
|
||||
"max_downhill_pct": round(max_downhill_pct * 100, 2),
|
||||
"slope_violations": slope_violations,
|
||||
"curve_violations": curve_violations,
|
||||
"min_curve_radius_m": round(min_curve_radius_actual, 2)
|
||||
if math.isfinite(min_curve_radius_actual)
|
||||
else None,
|
||||
"min_curve_radius_limit_m": round(min_curve_radius_m, 2),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
"""B05 경로 설계 결과의 aiomysql Raw SQL 접근.
|
||||
|
||||
routes(경로), route_points(렌더링 샘플), route_statistics(통계) 테이블에
|
||||
메타데이터와 상대 경로를 기록한다. 전체 폴리라인은 route_data_path의
|
||||
GeoJSON 파일에 저장하고 DB에는 경로만 기록한다.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import aiomysql
|
||||
|
||||
_STAGE_ROOT = "B05_wf2_Route"
|
||||
|
||||
|
||||
def _validate_stage_path(relative_path: str) -> str:
|
||||
normalized = PurePosixPath(relative_path.replace("\\", "/"))
|
||||
if normalized.is_absolute() or ".." in normalized.parts:
|
||||
raise ValueError("DB에는 프로젝트 루트 기준 상대 경로만 저장할 수 있습니다.")
|
||||
if not normalized.parts or normalized.parts[0] != _STAGE_ROOT:
|
||||
raise ValueError(f"B05 산출물 경로는 {_STAGE_ROOT} 아래여야 합니다.")
|
||||
return normalized.as_posix()
|
||||
|
||||
|
||||
async def create_route(
|
||||
connection: aiomysql.Connection,
|
||||
*,
|
||||
project_id: UUID,
|
||||
surface_model_id: int | None,
|
||||
total_length_m: float | None,
|
||||
start_chainage_m: float | None,
|
||||
end_chainage_m: float | None,
|
||||
grade_percent: list[float] | None,
|
||||
constraints: dict[str, Any] | None,
|
||||
algorithm_params: dict[str, Any] | None,
|
||||
route_data_path: str,
|
||||
status: str = "DRAFT",
|
||||
) -> int:
|
||||
"""경로 메타데이터를 저장하고 생성된 ID를 반환한다."""
|
||||
route_rel = _validate_stage_path(route_data_path)
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
INSERT INTO routes (
|
||||
project_id, surface_model_id, status,
|
||||
start_chainage_m, end_chainage_m, total_length_m,
|
||||
grade_percent, constraints, algorithm_params,
|
||||
route_data_path, computed_at
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
|
||||
""",
|
||||
(
|
||||
str(project_id),
|
||||
surface_model_id,
|
||||
status,
|
||||
start_chainage_m,
|
||||
end_chainage_m,
|
||||
total_length_m,
|
||||
json.dumps(grade_percent) if grade_percent is not None else None,
|
||||
json.dumps(constraints, ensure_ascii=False) if constraints is not None else None,
|
||||
json.dumps(algorithm_params, ensure_ascii=False)
|
||||
if algorithm_params is not None
|
||||
else None,
|
||||
route_rel,
|
||||
),
|
||||
)
|
||||
route_id = cursor.lastrowid
|
||||
if not route_id:
|
||||
raise RuntimeError("routes 레코드 생성 결과에 ID가 없습니다.")
|
||||
return int(route_id)
|
||||
|
||||
|
||||
async def insert_route_points(
|
||||
connection: aiomysql.Connection,
|
||||
route_id: int,
|
||||
points: list[dict[str, Any]],
|
||||
) -> int:
|
||||
"""경로 렌더링 샘플 포인트를 일괄 저장하고 저장 건수를 반환한다.
|
||||
|
||||
각 point dict: {chainage_m, elevation_m, slope_percent, sequence_num}
|
||||
"""
|
||||
if not points:
|
||||
return 0
|
||||
rows = [
|
||||
(
|
||||
route_id,
|
||||
point.get("chainage_m"),
|
||||
point.get("elevation_m"),
|
||||
point.get("slope_percent"),
|
||||
point.get("sequence_num"),
|
||||
)
|
||||
for point in points
|
||||
]
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.executemany(
|
||||
"""
|
||||
INSERT INTO route_points (
|
||||
route_id, chainage_m, elevation_m, slope_percent, sequence_num
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
""",
|
||||
rows,
|
||||
)
|
||||
return len(rows)
|
||||
|
||||
|
||||
async def create_route_statistics(
|
||||
connection: aiomysql.Connection,
|
||||
*,
|
||||
route_id: int,
|
||||
min_slope: float | None,
|
||||
max_slope: float | None,
|
||||
mean_slope: float | None,
|
||||
cut_volume_m3: float | None = None,
|
||||
fill_volume_m3: float | None = None,
|
||||
tree_cutting_volume: float | None = None,
|
||||
cost_score: float | None = None,
|
||||
) -> int:
|
||||
"""경로 통계를 저장하고 생성된 ID를 반환한다."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
INSERT INTO route_statistics (
|
||||
route_id, min_slope, max_slope, mean_slope,
|
||||
cut_volume_m3, fill_volume_m3, tree_cutting_volume, cost_score
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
route_id,
|
||||
min_slope,
|
||||
max_slope,
|
||||
mean_slope,
|
||||
cut_volume_m3,
|
||||
fill_volume_m3,
|
||||
tree_cutting_volume,
|
||||
cost_score,
|
||||
),
|
||||
)
|
||||
stat_id = cursor.lastrowid
|
||||
if not stat_id:
|
||||
raise RuntimeError("route_statistics 레코드 생성 결과에 ID가 없습니다.")
|
||||
return int(stat_id)
|
||||
|
||||
|
||||
async def get_latest_route(
|
||||
connection: aiomysql.Connection, project_id: UUID
|
||||
) -> dict[str, Any] | None:
|
||||
"""프로젝트의 최신 경로를 조회한다 (없으면 None)."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT id, status, total_length_m, route_data_path, computed_at
|
||||
FROM routes
|
||||
WHERE project_id = %s
|
||||
ORDER BY computed_at DESC, id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(str(project_id),),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"id": int(row[0]),
|
||||
"status": row[1],
|
||||
"total_length_m": row[2],
|
||||
"route_data_path": row[3],
|
||||
"computed_at": row[4].isoformat() if row[4] else None,
|
||||
}
|
||||
|
||||
|
||||
async def confirm_route(connection: aiomysql.Connection, route_id: int) -> None:
|
||||
"""경로 상태를 CONFIRMED로 변경한다."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"UPDATE routes SET status = 'CONFIRMED' WHERE id = %s",
|
||||
(route_id,),
|
||||
)
|
||||
@@ -0,0 +1,133 @@
|
||||
"""B05 경로 설계 FastAPI 라우터."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine import run_route_design
|
||||
from B05_wf2_Route.B05_wf2_Route_Repository import (
|
||||
confirm_route,
|
||||
create_route,
|
||||
create_route_statistics,
|
||||
get_latest_route,
|
||||
insert_route_points,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Schema import (
|
||||
RouteConfirmResponse,
|
||||
RouteSolveRequest,
|
||||
RouteSolveResponse,
|
||||
)
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B05 Route Design"])
|
||||
|
||||
|
||||
@router.post("/{project_id}/route/solve", response_model=RouteSolveResponse)
|
||||
async def solve_route(
|
||||
project_id: UUID, request: RouteSolveRequest
|
||||
) -> RouteSolveResponse | JSONResponse:
|
||||
"""경로 탐색을 실행하고 결과를 GeoJSON 저장 + DB 기록한다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
|
||||
# 무거운 경로 탐색은 이벤트 루프를 막지 않도록 별도 스레드에서 실행.
|
||||
design = await asyncio.to_thread(
|
||||
run_route_design,
|
||||
project_root,
|
||||
request.filter_key,
|
||||
request.method,
|
||||
request.smooth,
|
||||
request.points_data(),
|
||||
request.options(),
|
||||
request.algorithm,
|
||||
)
|
||||
solver = design["solver_result"]
|
||||
metrics = solver["metrics"]
|
||||
|
||||
await connection.begin()
|
||||
try:
|
||||
route_id = await create_route(
|
||||
connection,
|
||||
project_id=project_id,
|
||||
surface_model_id=request.surface_model_id,
|
||||
total_length_m=metrics.get("length_m"),
|
||||
start_chainage_m=0.0,
|
||||
end_chainage_m=metrics.get("length_m"),
|
||||
grade_percent=design["grade_percent"],
|
||||
constraints=design["constraints"],
|
||||
algorithm_params=design["algorithm_params"],
|
||||
route_data_path=design["route_data_path"],
|
||||
)
|
||||
await insert_route_points(connection, route_id, design["render_points"])
|
||||
stats = design["statistics"]
|
||||
await create_route_statistics(
|
||||
connection,
|
||||
route_id=route_id,
|
||||
min_slope=stats["min_slope"],
|
||||
max_slope=stats["max_slope"],
|
||||
mean_slope=stats["mean_slope"],
|
||||
cost_score=stats["cost_score"],
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
|
||||
return RouteSolveResponse(
|
||||
project_id=str(project_id),
|
||||
route_id=route_id,
|
||||
total_length_m=metrics.get("length_m", 0.0),
|
||||
metrics=metrics,
|
||||
required_points_ok=solver["required_points_ok"],
|
||||
route_data_path=design["route_data_path"],
|
||||
)
|
||||
except LookupError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except FileNotFoundError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except (OSError, ValueError) as exc:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception("B05 경로 탐색 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "경로 탐색 처리 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/route/confirm", response_model=RouteConfirmResponse)
|
||||
async def confirm_latest_route(project_id: UUID) -> RouteConfirmResponse | JSONResponse:
|
||||
"""프로젝트의 최신 경로를 확정(CONFIRMED)한다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
latest = await get_latest_route(connection, project_id)
|
||||
if not latest:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "확정할 경로가 없습니다."},
|
||||
)
|
||||
await connection.begin()
|
||||
try:
|
||||
await confirm_route(connection, latest["id"])
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
return RouteConfirmResponse(project_id=str(project_id), route_id=latest["id"])
|
||||
except Exception:
|
||||
logger.exception("B05 경로 확정 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "경로 확정 처리 중 오류가 발생했습니다."},
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
"""B05 경로 설계 요청·응답 검증 모델."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from config.config_system import ROUTE_GRADE_CLASSES
|
||||
|
||||
|
||||
class RoutePoint(BaseModel):
|
||||
"""경로 제어점 (BP/CP/EP)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
x: float
|
||||
y: float
|
||||
order: int | None = None
|
||||
|
||||
|
||||
class CirclePoint(BaseModel):
|
||||
"""회피/금지 원 (AP/FP)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
x: float
|
||||
y: float
|
||||
radius_m: float = Field(gt=0)
|
||||
|
||||
|
||||
class RouteSolveRequest(BaseModel):
|
||||
"""경로 탐색 실행 요청."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
filter_key: str = Field(description="지면 필터 키 (grid_min_z/csf/pmf)")
|
||||
method: str = Field(default="dtm", description="지표면 표현 (dtm/tin/nurbs/implicit/meshfree)")
|
||||
smooth: bool = Field(default=False)
|
||||
surface_model_id: int | None = Field(default=None, description="기반 지표면 모델 id")
|
||||
algorithm: str = Field(default="dijkstra", description="경로 알고리즘 (dijkstra/ridge_valley)")
|
||||
|
||||
bp: RoutePoint
|
||||
ep: RoutePoint
|
||||
cp: list[RoutePoint] = Field(default_factory=list)
|
||||
ap: list[CirclePoint] = Field(default_factory=list)
|
||||
fp: list[CirclePoint] = Field(default_factory=list)
|
||||
|
||||
grade_class: str = Field(default="trunk")
|
||||
paved: bool = Field(default=False)
|
||||
min_curve_radius_m: float | None = None
|
||||
max_uphill_grade: float | None = None
|
||||
max_downhill_grade: float | None = None
|
||||
weights: dict[str, float] | None = None
|
||||
allow_avoid_pass_through: bool = Field(default=False)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_choices(self) -> "RouteSolveRequest":
|
||||
if self.grade_class not in ROUTE_GRADE_CLASSES:
|
||||
raise ValueError(f"임도 등급은 {ROUTE_GRADE_CLASSES} 중 하나여야 합니다.")
|
||||
if self.algorithm not in ("dijkstra", "ridge_valley"):
|
||||
raise ValueError("경로 알고리즘은 dijkstra 또는 ridge_valley여야 합니다.")
|
||||
return self
|
||||
|
||||
def points_data(self) -> dict[str, Any]:
|
||||
return {
|
||||
"bp": self.bp.model_dump(),
|
||||
"ep": self.ep.model_dump(),
|
||||
"cp": [p.model_dump() for p in self.cp],
|
||||
"ap": [p.model_dump() for p in self.ap],
|
||||
"fp": [p.model_dump() for p in self.fp],
|
||||
}
|
||||
|
||||
def options(self) -> dict[str, Any]:
|
||||
return {
|
||||
"grade_class": self.grade_class,
|
||||
"paved": self.paved,
|
||||
"min_curve_radius_m": self.min_curve_radius_m,
|
||||
"max_uphill_grade": self.max_uphill_grade,
|
||||
"max_downhill_grade": self.max_downhill_grade,
|
||||
"weights": self.weights,
|
||||
"allow_avoid_pass_through": self.allow_avoid_pass_through,
|
||||
}
|
||||
|
||||
|
||||
class RouteSolveResponse(BaseModel):
|
||||
"""경로 탐색 실행 결과."""
|
||||
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
route_id: int
|
||||
total_length_m: float
|
||||
metrics: dict[str, Any]
|
||||
required_points_ok: bool
|
||||
route_data_path: str
|
||||
|
||||
|
||||
class RouteConfirmResponse(BaseModel):
|
||||
"""경로 확정 결과."""
|
||||
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
route_id: int
|
||||
confirmed: bool = True
|
||||
@@ -0,0 +1,117 @@
|
||||
"""B06 종횡단 생성 엔진 오케스트레이터.
|
||||
|
||||
확정 경로 GeoJSON과 확정 지표면 모델 sampler로 종단·횡단을 생성하고, 종단은
|
||||
longitudinal/, 각 횡단은 cross_sections/ 아래 파일로 저장한다. DB 기록용
|
||||
데이터(경로·요약·측점별 파일)를 준비해 반환한다. 라우터에서 asyncio.to_thread로
|
||||
호출한다.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Sampler import build_surface_sampler
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Section import (
|
||||
SectionGenerationOptions,
|
||||
generate_sections,
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
|
||||
_STAGE_SUBDIR = Path("B06_wf3_ProfileCross")
|
||||
_MODELS_SUBDIR = Path("B04_wf1_Surface") / "models"
|
||||
|
||||
|
||||
def _load_route_polyline(project_root: Path, route_data_path: str) -> list[list[float]]:
|
||||
"""B05가 저장한 경로 GeoJSON에서 3D 폴리라인 좌표열을 로드한다."""
|
||||
geojson_path = project_root / Path(route_data_path)
|
||||
if not geojson_path.is_file():
|
||||
raise FileNotFoundError(f"경로 GeoJSON을 찾을 수 없습니다: {route_data_path}")
|
||||
geo = json.loads(geojson_path.read_text(encoding="utf-8"))
|
||||
coords = geo.get("geometry", {}).get("coordinates")
|
||||
if not coords or len(coords) < 2:
|
||||
raise ValueError("경로 GeoJSON에 유효한 LineString 좌표가 없습니다.")
|
||||
return [[float(c[0]), float(c[1]), float(c[2]) if len(c) > 2 else 0.0] for c in coords]
|
||||
|
||||
|
||||
def _cross_summary(cross_section: dict[str, Any]) -> dict[str, Any]:
|
||||
"""횡단면 상세에서 DB data 컬럼에 저장할 요약을 만든다."""
|
||||
samples = cross_section.get("samples", [])
|
||||
valid_z = [s["elevation_m"] for s in samples if s.get("valid") and s.get("elevation_m") is not None]
|
||||
return {
|
||||
"chainage_m": cross_section.get("chainage_m"),
|
||||
"center_z": cross_section.get("center_z"),
|
||||
"azimuth_deg": cross_section.get("azimuth_deg"),
|
||||
"sample_count": len(samples),
|
||||
"min_elevation_m": min(valid_z) if valid_z else None,
|
||||
"max_elevation_m": max(valid_z) if valid_z else None,
|
||||
}
|
||||
|
||||
|
||||
def run_section_generation(
|
||||
project_root: Path,
|
||||
route_data_path: str,
|
||||
filter_key: str,
|
||||
method: str,
|
||||
smooth: bool,
|
||||
*,
|
||||
options: SectionGenerationOptions | None = None,
|
||||
crs: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""종횡단을 생성·저장하고 DB 기록용 데이터를 반환한다.
|
||||
|
||||
반환 dict:
|
||||
- longitudinal: {file_path, data(요약)}
|
||||
- cross_sections: [{chainage_m, sequence_num, data(요약), cross_section_file_path}]
|
||||
- result: generate_sections 원본 결과
|
||||
"""
|
||||
polyline = _load_route_polyline(project_root, route_data_path)
|
||||
models_dir = project_root / _MODELS_SUBDIR
|
||||
sampler = build_surface_sampler(models_dir, filter_key, method, smooth)
|
||||
|
||||
result = generate_sections(
|
||||
polyline,
|
||||
sampler,
|
||||
options,
|
||||
source_snapshot={"filter": filter_key, "method": method, "smooth": smooth},
|
||||
crs=crs,
|
||||
)
|
||||
|
||||
stage_root = project_root / _STAGE_SUBDIR
|
||||
long_dir = stage_root / "longitudinal"
|
||||
cross_dir = stage_root / "cross_sections"
|
||||
long_dir.mkdir(parents=True, exist_ok=True)
|
||||
cross_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 종단면 저장
|
||||
long_file = long_dir / "longitudinal.json"
|
||||
atomic_write_json(long_file, result["longitudinal"])
|
||||
long_summary = {
|
||||
"length_m": result["longitudinal"]["length_m"],
|
||||
"station_count": result["summary"]["station_count"],
|
||||
"invalid_samples": result["summary"]["invalid_longitudinal_samples"],
|
||||
}
|
||||
|
||||
# 측점별 횡단면 저장
|
||||
cross_records: list[dict[str, Any]] = []
|
||||
for seq, cross_section in enumerate(result["cross_sections"]):
|
||||
chainage = float(cross_section["chainage_m"])
|
||||
filename = f"cross_{int(round(chainage)):05d}m.json"
|
||||
cross_file = cross_dir / filename
|
||||
atomic_write_json(cross_file, cross_section)
|
||||
cross_records.append(
|
||||
{
|
||||
"chainage_m": chainage,
|
||||
"sequence_num": seq,
|
||||
"data": _cross_summary(cross_section),
|
||||
"cross_section_file_path": cross_file.relative_to(project_root).as_posix(),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"longitudinal": {
|
||||
"file_path": long_file.relative_to(project_root).as_posix(),
|
||||
"data": long_summary,
|
||||
},
|
||||
"cross_sections": cross_records,
|
||||
"result": result,
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"""B06 지표면 표고 sampler.
|
||||
|
||||
종·횡단 생성기가 의존하는 최소 표고 조회 인터페이스와, 확정된 지표면 모델
|
||||
(B04_wf1_Surface/models)을 일괄 XY 표고 sampler로 여는 팩토리를 제공한다.
|
||||
DTM valid_mask를 footprint로 결합해 데이터가 없는 영역을 임의 표고로 메우지
|
||||
않는다.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
import numpy as np
|
||||
from scipy.interpolate import RegularGridInterpolator
|
||||
|
||||
|
||||
class SurfaceElevationSampler(Protocol):
|
||||
"""종·횡단 생성기가 의존하는 최소 표고 조회 인터페이스."""
|
||||
|
||||
def sample_xy(self, xy: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""(N, 2) 모델좌표 XY 배열에 대해 (z, valid)를 반환한다."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class DtmGridSampler:
|
||||
"""정규 DTM의 표고와 valid_mask를 보수적으로 조회한다.
|
||||
|
||||
보간점 주변 네 격자 꼭짓점이 모두 유효할 때만 valid=True로 반환한다.
|
||||
"""
|
||||
|
||||
x: np.ndarray
|
||||
y: np.ndarray
|
||||
z: np.ndarray
|
||||
valid_mask: np.ndarray
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.x = np.asarray(self.x, dtype=np.float64).reshape(-1)
|
||||
self.y = np.asarray(self.y, dtype=np.float64).reshape(-1)
|
||||
self.z = np.asarray(self.z, dtype=np.float64)
|
||||
self.valid_mask = np.asarray(self.valid_mask, dtype=bool)
|
||||
if len(self.x) < 2 or len(self.y) < 2:
|
||||
raise ValueError("DTM 표고 조회에는 X/Y 축이 각각 2개 이상 필요합니다.")
|
||||
if self.z.shape != (len(self.y), len(self.x)):
|
||||
raise ValueError("DTM Z 격자 크기가 X/Y 축과 일치하지 않습니다.")
|
||||
if self.valid_mask.shape != self.z.shape:
|
||||
raise ValueError("DTM valid_mask 크기가 Z 격자와 일치하지 않습니다.")
|
||||
if self.x[0] > self.x[-1]:
|
||||
self.x = self.x[::-1]
|
||||
self.z = self.z[:, ::-1]
|
||||
self.valid_mask = self.valid_mask[:, ::-1]
|
||||
if self.y[0] > self.y[-1]:
|
||||
self.y = self.y[::-1]
|
||||
self.z = self.z[::-1, :]
|
||||
self.valid_mask = self.valid_mask[::-1, :]
|
||||
self._interpolator = RegularGridInterpolator(
|
||||
(self.y, self.x), self.z, method="linear", bounds_error=False, fill_value=np.nan
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_npz(cls, path: Path | str) -> "DtmGridSampler":
|
||||
with np.load(Path(path), allow_pickle=False) as data:
|
||||
return cls(data["x"], data["y"], data["z"], data["valid_mask"])
|
||||
|
||||
def sample_xy(self, xy: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
xy = np.asarray(xy, dtype=np.float64)
|
||||
if xy.ndim != 2 or xy.shape[1] != 2:
|
||||
raise ValueError("표고 조회 좌표는 (N, 2) XY 배열이어야 합니다.")
|
||||
if not len(xy):
|
||||
return np.empty(0, dtype=np.float64), np.empty(0, dtype=bool)
|
||||
|
||||
z = np.asarray(
|
||||
self._interpolator(np.column_stack([xy[:, 1], xy[:, 0]])), dtype=np.float64
|
||||
)
|
||||
ix = np.searchsorted(self.x, xy[:, 0], side="right") - 1
|
||||
iy = np.searchsorted(self.y, xy[:, 1], side="right") - 1
|
||||
inside = (ix >= 0) & (iy >= 0) & (ix < len(self.x) - 1) & (iy < len(self.y) - 1)
|
||||
valid = np.zeros(len(xy), dtype=bool)
|
||||
selected = np.flatnonzero(inside)
|
||||
if len(selected):
|
||||
sx = ix[selected]
|
||||
sy = iy[selected]
|
||||
valid[selected] = (
|
||||
self.valid_mask[sy, sx]
|
||||
& self.valid_mask[sy, sx + 1]
|
||||
& self.valid_mask[sy + 1, sx]
|
||||
& self.valid_mask[sy + 1, sx + 1]
|
||||
& np.isfinite(z[selected])
|
||||
)
|
||||
z[~valid] = np.nan
|
||||
return z, valid
|
||||
|
||||
|
||||
@dataclass
|
||||
class CallableSurfaceSampler:
|
||||
"""테스트와 어댑터에 사용할 함수 기반 sampler."""
|
||||
|
||||
function: Callable[[np.ndarray], np.ndarray]
|
||||
|
||||
def sample_xy(self, xy: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
values = np.asarray(self.function(np.asarray(xy, dtype=np.float64)), dtype=np.float64)
|
||||
if values.shape != (len(xy),):
|
||||
raise ValueError("표고 함수는 입력 좌표 수와 같은 길이의 배열을 반환해야 합니다.")
|
||||
valid = np.isfinite(values)
|
||||
return values, valid
|
||||
|
||||
|
||||
@dataclass
|
||||
class InterpolatedSurfaceSampler:
|
||||
"""불규칙/곡면 모델 보간기와 DTM footprint 유효성을 결합한다."""
|
||||
|
||||
interpolator: Callable[[np.ndarray], np.ndarray]
|
||||
footprint: SurfaceElevationSampler | None = None
|
||||
|
||||
def sample_xy(self, xy: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
xy = np.asarray(xy, dtype=np.float64)
|
||||
values = np.asarray(self.interpolator(xy), dtype=np.float64).reshape(-1)
|
||||
valid = np.isfinite(values)
|
||||
if self.footprint is not None:
|
||||
_, footprint_valid = self.footprint.sample_xy(xy)
|
||||
valid &= footprint_valid
|
||||
values[~valid] = np.nan
|
||||
return values, valid
|
||||
|
||||
|
||||
def build_surface_sampler(
|
||||
models_dir: Path | str, source_filter: str, method: str, smooth: bool
|
||||
) -> SurfaceElevationSampler:
|
||||
"""1단계 확정 모델을 종·횡단용 일괄 XY 표고 sampler로 연다."""
|
||||
models_dir = Path(models_dir)
|
||||
smooth_suffix = "_smooth" if smooth and method in {"dtm", "tin"} else ""
|
||||
dtm_smooth = models_dir / f"dtm_{source_filter}_smooth.npz"
|
||||
dtm_original = models_dir / f"dtm_{source_filter}.npz"
|
||||
dtm_path = dtm_smooth if smooth and dtm_smooth.exists() else dtm_original
|
||||
if not dtm_path.exists():
|
||||
raise FileNotFoundError(f"기준 DTM이 없습니다: {dtm_path.name}")
|
||||
footprint = DtmGridSampler.from_npz(dtm_path)
|
||||
if method == "dtm":
|
||||
return footprint
|
||||
|
||||
if method == "tin":
|
||||
from scipy.interpolate import LinearNDInterpolator
|
||||
|
||||
path = models_dir / f"tin_{source_filter}{smooth_suffix}.npz"
|
||||
if not path.exists() and smooth_suffix:
|
||||
path = models_dir / f"tin_{source_filter}.npz"
|
||||
with np.load(path, allow_pickle=False) as data:
|
||||
vertices = np.asarray(data["vertices"], dtype=np.float64)
|
||||
interpolator = LinearNDInterpolator(vertices[:, :2], vertices[:, 2], fill_value=np.nan)
|
||||
return InterpolatedSurfaceSampler(lambda xy: interpolator(xy), footprint)
|
||||
|
||||
if method == "nurbs":
|
||||
from scipy.interpolate import RectBivariateSpline
|
||||
|
||||
path = models_dir / f"nurbs_{source_filter}.npz"
|
||||
with np.load(path, allow_pickle=False) as data:
|
||||
control_x = np.asarray(data["control_x"], dtype=np.float64)
|
||||
control_y = np.asarray(data["control_y"], dtype=np.float64)
|
||||
control_z = np.asarray(data["control_z"], dtype=np.float64)
|
||||
degree = int(data["degree"][0]) if "degree" in data else 3
|
||||
spline = RectBivariateSpline(
|
||||
control_y,
|
||||
control_x,
|
||||
control_z,
|
||||
kx=min(degree, len(control_y) - 1),
|
||||
ky=min(degree, len(control_x) - 1),
|
||||
)
|
||||
return InterpolatedSurfaceSampler(lambda xy: spline.ev(xy[:, 1], xy[:, 0]), footprint)
|
||||
|
||||
if method == "implicit":
|
||||
from scipy.interpolate import RBFInterpolator
|
||||
|
||||
path = models_dir / f"implicit_{source_filter}.npz"
|
||||
with np.load(path, allow_pickle=False) as data:
|
||||
centers = np.asarray(data["centers_xy"], dtype=np.float64)
|
||||
center_z = np.asarray(data["center_z"], dtype=np.float64)
|
||||
smoothing = float(data["smoothing"][0]) if "smoothing" in data else 0.0
|
||||
interpolator = RBFInterpolator(
|
||||
centers,
|
||||
center_z,
|
||||
neighbors=min(64, len(centers)),
|
||||
smoothing=smoothing,
|
||||
kernel="thin_plate_spline",
|
||||
)
|
||||
return InterpolatedSurfaceSampler(lambda xy: interpolator(xy), footprint)
|
||||
|
||||
if method == "meshfree":
|
||||
from scipy.interpolate import LinearNDInterpolator
|
||||
|
||||
path = models_dir / f"meshfree_{source_filter}.npz"
|
||||
with np.load(path, allow_pickle=False) as data:
|
||||
points = np.asarray(data["points"], dtype=np.float64)
|
||||
interpolator = LinearNDInterpolator(points[:, :2], points[:, 2], fill_value=np.nan)
|
||||
return InterpolatedSurfaceSampler(lambda xy: interpolator(xy), footprint)
|
||||
|
||||
raise ValueError(f"지원하지 않는 지표면 모델입니다: {method}")
|
||||
@@ -0,0 +1,266 @@
|
||||
"""B06 종단·횡단 원시 데이터 생성.
|
||||
|
||||
확정 경로 폴리라인과 표고 sampler로 CAD 인계 가능한 종단(longitudinal)·횡단
|
||||
(cross) 데이터를 생성한다. BP(0m)부터 station_interval 간격 측점을 만들고
|
||||
각 측점에서 경로 접선의 좌우 방향으로 횡단을 샘플링한다.
|
||||
"""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Sampler import SurfaceElevationSampler
|
||||
from config.config_system import (
|
||||
SECTION_CROSS_HALF_WIDTH_M,
|
||||
SECTION_CROSS_SAMPLE_INTERVAL_M,
|
||||
SECTION_INCLUDE_ENDPOINT,
|
||||
SECTION_LONG_SAMPLE_INTERVAL_M,
|
||||
SECTION_STATION_INTERVAL_M,
|
||||
)
|
||||
|
||||
SECTION_SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SectionGenerationOptions:
|
||||
station_interval_m: float = SECTION_STATION_INTERVAL_M
|
||||
cross_half_width_m: float = SECTION_CROSS_HALF_WIDTH_M
|
||||
cross_sample_interval_m: float = SECTION_CROSS_SAMPLE_INTERVAL_M
|
||||
long_sample_interval_m: float = SECTION_LONG_SAMPLE_INTERVAL_M
|
||||
include_endpoint: bool = SECTION_INCLUDE_ENDPOINT
|
||||
|
||||
def validate(self) -> None:
|
||||
values = {
|
||||
"횡단 측점 간격": self.station_interval_m,
|
||||
"횡단 좌우 폭": self.cross_half_width_m,
|
||||
"횡단 샘플 간격": self.cross_sample_interval_m,
|
||||
"종단 샘플 간격": self.long_sample_interval_m,
|
||||
}
|
||||
for label, value in values.items():
|
||||
if not math.isfinite(value) or value <= 0:
|
||||
raise ValueError(f"{label}은 0보다 큰 유한한 값이어야 합니다.")
|
||||
if self.cross_sample_interval_m > self.cross_half_width_m * 2:
|
||||
raise ValueError("횡단 샘플 간격이 전체 횡단 폭보다 클 수 없습니다.")
|
||||
|
||||
|
||||
def format_station(chainage_m: float) -> str:
|
||||
"""WebCAD 도면에서도 재사용할 수 있는 STA.k+mmm.mmm 표기."""
|
||||
chainage_m = max(float(chainage_m), 0.0)
|
||||
km = int(chainage_m // 1000.0)
|
||||
remainder = chainage_m - km * 1000.0
|
||||
return f"STA.{km}+{remainder:07.3f}"
|
||||
|
||||
|
||||
def _clean_polyline(polyline: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
points = np.asarray(polyline, dtype=np.float64)
|
||||
if points.ndim != 2 or points.shape[1] < 2 or len(points) < 2:
|
||||
raise ValueError("경로는 최소 2개의 (x, y, z) 좌표로 구성되어야 합니다.")
|
||||
if points.shape[1] == 2:
|
||||
points = np.column_stack([points, np.full(len(points), np.nan)])
|
||||
else:
|
||||
points = points[:, :3]
|
||||
if not np.all(np.isfinite(points[:, :2])):
|
||||
raise ValueError("경로 XY 좌표에 NaN 또는 Infinity가 있습니다.")
|
||||
|
||||
distances = np.hypot(np.diff(points[:, 0]), np.diff(points[:, 1]))
|
||||
keep = np.r_[True, distances > 1e-8]
|
||||
points = points[keep]
|
||||
if len(points) < 2:
|
||||
raise ValueError("수평 길이가 있는 경로 구간이 없습니다.")
|
||||
distances = np.hypot(np.diff(points[:, 0]), np.diff(points[:, 1]))
|
||||
chainage = np.r_[0.0, np.cumsum(distances)]
|
||||
return points, chainage
|
||||
|
||||
|
||||
def _chainages(total: float, interval: float, include_endpoint: bool) -> np.ndarray:
|
||||
values = np.arange(0.0, total + 1e-9, interval, dtype=np.float64)
|
||||
if not len(values) or abs(values[0]) > 1e-9:
|
||||
values = np.r_[0.0, values]
|
||||
if include_endpoint and total - values[-1] > 1e-6:
|
||||
values = np.r_[values, total]
|
||||
return values
|
||||
|
||||
|
||||
def _interpolate_xy(points: np.ndarray, chainage: np.ndarray, targets: np.ndarray) -> np.ndarray:
|
||||
return np.column_stack(
|
||||
[
|
||||
np.interp(targets, chainage, points[:, 0]),
|
||||
np.interp(targets, chainage, points[:, 1]),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _tangent_at(
|
||||
points: np.ndarray, chainage: np.ndarray, target: float, probe: float
|
||||
) -> np.ndarray:
|
||||
total = float(chainage[-1])
|
||||
before = max(0.0, target - probe)
|
||||
after = min(total, target + probe)
|
||||
if after - before <= 1e-9:
|
||||
before = max(0.0, target - 1e-3)
|
||||
after = min(total, target + 1e-3)
|
||||
pair = _interpolate_xy(points, chainage, np.array([before, after]))
|
||||
vector = pair[1] - pair[0]
|
||||
length = float(np.hypot(vector[0], vector[1]))
|
||||
if length <= 1e-9:
|
||||
raise ValueError(f"측점 {target:.3f}m에서 경로 접선 방향을 계산할 수 없습니다.")
|
||||
return vector / length
|
||||
|
||||
|
||||
def _float_or_none(value: float) -> float | None:
|
||||
return round(float(value), 6) if math.isfinite(float(value)) else None
|
||||
|
||||
|
||||
def generate_sections(
|
||||
polyline: np.ndarray | list[list[float]],
|
||||
sampler: SurfaceElevationSampler,
|
||||
options: SectionGenerationOptions | None = None,
|
||||
*,
|
||||
source_snapshot: dict[str, Any] | None = None,
|
||||
crs: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다."""
|
||||
options = options or SectionGenerationOptions()
|
||||
options.validate()
|
||||
points, route_chainage = _clean_polyline(np.asarray(polyline, dtype=np.float64))
|
||||
total = float(route_chainage[-1])
|
||||
|
||||
long_chainage = _chainages(total, options.long_sample_interval_m, True)
|
||||
long_xy = _interpolate_xy(points, route_chainage, long_chainage)
|
||||
long_z, long_valid = sampler.sample_xy(long_xy)
|
||||
|
||||
station_chainage = _chainages(total, options.station_interval_m, options.include_endpoint)
|
||||
station_xy = _interpolate_xy(points, route_chainage, station_chainage)
|
||||
tangents = np.vstack(
|
||||
[
|
||||
_tangent_at(
|
||||
points, route_chainage, float(value), max(options.long_sample_interval_m, 0.5)
|
||||
)
|
||||
for value in station_chainage
|
||||
]
|
||||
)
|
||||
left_axes = np.column_stack([-tangents[:, 1], tangents[:, 0]])
|
||||
|
||||
offsets = np.arange(
|
||||
-options.cross_half_width_m,
|
||||
options.cross_half_width_m + options.cross_sample_interval_m * 0.5,
|
||||
options.cross_sample_interval_m,
|
||||
dtype=np.float64,
|
||||
)
|
||||
offsets = offsets[offsets <= options.cross_half_width_m + 1e-9]
|
||||
if not np.any(np.isclose(offsets, 0.0, atol=1e-9)):
|
||||
offsets = np.sort(np.r_[offsets, 0.0])
|
||||
|
||||
all_cross_xy = (
|
||||
station_xy[:, None, :] + left_axes[:, None, :] * offsets[None, :, None]
|
||||
).reshape(-1, 2)
|
||||
all_cross_z, all_cross_valid = sampler.sample_xy(all_cross_xy)
|
||||
all_cross_z = all_cross_z.reshape(len(station_chainage), len(offsets))
|
||||
all_cross_valid = all_cross_valid.reshape(len(station_chainage), len(offsets))
|
||||
|
||||
stations: list[dict[str, Any]] = []
|
||||
cross_sections: list[dict[str, Any]] = []
|
||||
for index, value in enumerate(station_chainage):
|
||||
station_id = f"station_{int(round(float(value) * 1000)):012d}"
|
||||
kind = "bp" if index == 0 else "ep" if abs(float(value) - total) <= 1e-6 else "regular"
|
||||
center_index = int(np.argmin(np.abs(offsets)))
|
||||
center_z = all_cross_z[index, center_index]
|
||||
tangent = tangents[index]
|
||||
left = left_axes[index]
|
||||
azimuth = (math.degrees(math.atan2(tangent[0], tangent[1])) + 360.0) % 360.0
|
||||
frame = {
|
||||
"origin": {
|
||||
"x": round(float(station_xy[index, 0]), 6),
|
||||
"y": round(float(station_xy[index, 1]), 6),
|
||||
"z": _float_or_none(center_z),
|
||||
},
|
||||
"tangent_xy": [round(float(tangent[0]), 9), round(float(tangent[1]), 9)],
|
||||
"left_xy": [round(float(left[0]), 9), round(float(left[1]), 9)],
|
||||
"up_xyz": [0.0, 0.0, 1.0],
|
||||
}
|
||||
station = {
|
||||
"station_id": station_id,
|
||||
"chainage_m": round(float(value), 6),
|
||||
"label": format_station(float(value)),
|
||||
"kind": kind,
|
||||
"center_x": round(float(station_xy[index, 0]), 6),
|
||||
"center_y": round(float(station_xy[index, 1]), 6),
|
||||
"center_z": _float_or_none(center_z),
|
||||
"azimuth_deg": round(azimuth, 6),
|
||||
"frame": frame,
|
||||
}
|
||||
stations.append(station)
|
||||
|
||||
samples = []
|
||||
cross_xy_view = all_cross_xy.reshape(len(station_chainage), len(offsets), 2)
|
||||
for offset_index, offset in enumerate(offsets):
|
||||
valid = bool(all_cross_valid[index, offset_index])
|
||||
xy = cross_xy_view[index, offset_index]
|
||||
samples.append(
|
||||
{
|
||||
"offset_m": round(float(offset), 6),
|
||||
"x": round(float(xy[0]), 6),
|
||||
"y": round(float(xy[1]), 6),
|
||||
"z": _float_or_none(all_cross_z[index, offset_index]) if valid else None,
|
||||
"elevation_m": _float_or_none(all_cross_z[index, offset_index])
|
||||
if valid
|
||||
else None,
|
||||
"valid": valid,
|
||||
}
|
||||
)
|
||||
cross_sections.append({**station, "samples": samples})
|
||||
|
||||
longitudinal_samples = [
|
||||
{
|
||||
"chainage_m": round(float(chainage), 6),
|
||||
"x": round(float(xy[0]), 6),
|
||||
"y": round(float(xy[1]), 6),
|
||||
"z": _float_or_none(z) if valid else None,
|
||||
"elevation_m": _float_or_none(z) if valid else None,
|
||||
"valid": bool(valid),
|
||||
}
|
||||
for chainage, xy, z, valid in zip(long_chainage, long_xy, long_z, long_valid)
|
||||
]
|
||||
|
||||
finite_z = np.asarray(
|
||||
[sample["z"] for sample in longitudinal_samples if sample["z"] is not None]
|
||||
)
|
||||
datum = math.floor(float(finite_z.min()) / 10.0) * 10.0 if finite_z.size else None
|
||||
return {
|
||||
"schema_version": SECTION_SCHEMA_VERSION,
|
||||
"status": "completed",
|
||||
"source": source_snapshot or {},
|
||||
"coordinate_reference": {
|
||||
"crs": crs,
|
||||
"world_axes": {"x": "project_easting", "y": "project_northing", "z": "elevation"},
|
||||
"units": {"horizontal": "m", "vertical": "m", "angle": "degree"},
|
||||
},
|
||||
"cad_exchange": {
|
||||
"station_origin": "BP",
|
||||
"chainage_direction": "BP_to_EP",
|
||||
"cross_offset_sign": {"negative": "right", "positive": "left"},
|
||||
"cross_local_axes": {"x": "offset_m", "y": "elevation_m"},
|
||||
"recommended_drawing_datum_m": datum,
|
||||
},
|
||||
"options": {
|
||||
"station_interval_m": options.station_interval_m,
|
||||
"cross_half_width_m": options.cross_half_width_m,
|
||||
"cross_sample_interval_m": options.cross_sample_interval_m,
|
||||
"long_sample_interval_m": options.long_sample_interval_m,
|
||||
"include_endpoint": options.include_endpoint,
|
||||
},
|
||||
"longitudinal": {
|
||||
"length_m": round(total, 6),
|
||||
"samples": longitudinal_samples,
|
||||
"stations": stations,
|
||||
},
|
||||
"cross_sections": cross_sections,
|
||||
"summary": {
|
||||
"station_count": len(stations),
|
||||
"cross_sample_count": int(len(stations) * len(offsets)),
|
||||
"invalid_longitudinal_samples": int((~long_valid).sum()),
|
||||
"invalid_cross_samples": int((~all_cross_valid).sum()),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"""B06 종횡단 결과의 aiomysql Raw SQL 접근.
|
||||
|
||||
longitudinal_sections(종단면 1건), cross_sections(측점별 다건) 테이블에
|
||||
메타데이터와 상대 경로를 기록한다. 상세 샘플 데이터는 파일에 저장하고 DB에는
|
||||
요약 data(JSON)와 경로만 기록한다.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import aiomysql
|
||||
|
||||
_STAGE_ROOT = "B06_wf3_ProfileCross"
|
||||
|
||||
|
||||
def _validate_stage_path(relative_path: str) -> str:
|
||||
normalized = PurePosixPath(relative_path.replace("\\", "/"))
|
||||
if normalized.is_absolute() or ".." in normalized.parts:
|
||||
raise ValueError("DB에는 프로젝트 루트 기준 상대 경로만 저장할 수 있습니다.")
|
||||
if not normalized.parts or normalized.parts[0] != _STAGE_ROOT:
|
||||
raise ValueError(f"B06 산출물 경로는 {_STAGE_ROOT} 아래여야 합니다.")
|
||||
return normalized.as_posix()
|
||||
|
||||
|
||||
async def delete_sections_for_route(connection: aiomysql.Connection, route_id: int) -> None:
|
||||
"""경로 재생성 전에 기존 종횡단 레코드를 삭제한다 (멱등 재실행)."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute("DELETE FROM cross_sections WHERE route_id = %s", (route_id,))
|
||||
await cursor.execute("DELETE FROM longitudinal_sections WHERE route_id = %s", (route_id,))
|
||||
|
||||
|
||||
async def create_longitudinal_section(
|
||||
connection: aiomysql.Connection,
|
||||
*,
|
||||
project_id: UUID,
|
||||
route_id: int,
|
||||
data: dict[str, Any] | None,
|
||||
longitudinal_file_path: str,
|
||||
status: str = "DRAFT",
|
||||
) -> int:
|
||||
"""종단면 메타데이터를 저장하고 생성된 ID를 반환한다."""
|
||||
file_rel = _validate_stage_path(longitudinal_file_path)
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
INSERT INTO longitudinal_sections (
|
||||
project_id, route_id, computed_at, data, longitudinal_file_path, status
|
||||
)
|
||||
VALUES (%s, %s, CURRENT_TIMESTAMP, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
str(project_id),
|
||||
route_id,
|
||||
json.dumps(data, ensure_ascii=False) if data is not None else None,
|
||||
file_rel,
|
||||
status,
|
||||
),
|
||||
)
|
||||
new_id = cursor.lastrowid
|
||||
if not new_id:
|
||||
raise RuntimeError("longitudinal_sections 레코드 생성 결과에 ID가 없습니다.")
|
||||
return int(new_id)
|
||||
|
||||
|
||||
async def insert_cross_sections(
|
||||
connection: aiomysql.Connection,
|
||||
*,
|
||||
project_id: UUID,
|
||||
route_id: int,
|
||||
sections: list[dict[str, Any]],
|
||||
) -> int:
|
||||
"""측점별 횡단면 레코드를 일괄 저장하고 저장 건수를 반환한다.
|
||||
|
||||
각 section dict: {chainage_m, sequence_num, data, cross_section_file_path, status?}
|
||||
"""
|
||||
if not sections:
|
||||
return 0
|
||||
rows = []
|
||||
for section in sections:
|
||||
file_rel = _validate_stage_path(section["cross_section_file_path"])
|
||||
data = section.get("data")
|
||||
rows.append(
|
||||
(
|
||||
str(project_id),
|
||||
route_id,
|
||||
section.get("chainage_m"),
|
||||
section.get("sequence_num"),
|
||||
json.dumps(data, ensure_ascii=False) if data is not None else None,
|
||||
file_rel,
|
||||
section.get("status", "DRAFT"),
|
||||
)
|
||||
)
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.executemany(
|
||||
"""
|
||||
INSERT INTO cross_sections (
|
||||
project_id, route_id, chainage_m, sequence_num,
|
||||
data, cross_section_file_path, status
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
rows,
|
||||
)
|
||||
return len(rows)
|
||||
|
||||
|
||||
async def get_longitudinal_section(
|
||||
connection: aiomysql.Connection, project_id: UUID, route_id: int
|
||||
) -> dict[str, Any] | None:
|
||||
"""경로의 종단면 메타데이터를 조회한다 (없으면 None)."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT id, longitudinal_file_path, status, computed_at
|
||||
FROM longitudinal_sections
|
||||
WHERE project_id = %s AND route_id = %s
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(str(project_id), route_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"id": int(row[0]),
|
||||
"longitudinal_file_path": row[1],
|
||||
"status": row[2],
|
||||
"computed_at": row[3].isoformat() if row[3] else None,
|
||||
}
|
||||
|
||||
|
||||
async def confirm_sections_for_route(connection: aiomysql.Connection, route_id: int) -> None:
|
||||
"""경로의 종횡단면 상태를 CONFIRMED로 변경한다."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"UPDATE longitudinal_sections SET status = 'CONFIRMED' WHERE route_id = %s",
|
||||
(route_id,),
|
||||
)
|
||||
await cursor.execute(
|
||||
"UPDATE cross_sections SET status = 'CONFIRMED' WHERE route_id = %s",
|
||||
(route_id,),
|
||||
)
|
||||
@@ -0,0 +1,164 @@
|
||||
"""B06 종횡단 생성 FastAPI 라우터."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_wf2_Route.B05_wf2_Route_Repository import get_latest_route
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine import run_section_generation
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Section import SectionGenerationOptions
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import (
|
||||
confirm_sections_for_route,
|
||||
create_longitudinal_section,
|
||||
delete_sections_for_route,
|
||||
get_longitudinal_section,
|
||||
insert_cross_sections,
|
||||
)
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import (
|
||||
SectionConfirmResponse,
|
||||
SectionGenerateRequest,
|
||||
SectionGenerateResponse,
|
||||
SectionSummaryResponse,
|
||||
)
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"])
|
||||
|
||||
|
||||
def _build_options(request: SectionGenerateRequest) -> SectionGenerationOptions:
|
||||
"""요청의 옵션(미지정은 config 기본값)으로 SectionGenerationOptions를 만든다."""
|
||||
defaults = SectionGenerationOptions()
|
||||
return SectionGenerationOptions(
|
||||
station_interval_m=request.station_interval_m or defaults.station_interval_m,
|
||||
cross_half_width_m=request.cross_half_width_m or defaults.cross_half_width_m,
|
||||
cross_sample_interval_m=request.cross_sample_interval_m or defaults.cross_sample_interval_m,
|
||||
long_sample_interval_m=request.long_sample_interval_m or defaults.long_sample_interval_m,
|
||||
include_endpoint=defaults.include_endpoint,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/sections/generate", response_model=SectionGenerateResponse)
|
||||
async def generate_sections(
|
||||
project_id: UUID, request: SectionGenerateRequest
|
||||
) -> SectionGenerateResponse | JSONResponse:
|
||||
"""확정 경로에서 종횡단을 생성·저장하고 DB에 기록한다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
|
||||
latest = await get_latest_route(connection, project_id)
|
||||
if not latest or latest["id"] != request.route_id:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "대상 경로를 찾을 수 없습니다."},
|
||||
)
|
||||
route_data_path = latest["route_data_path"]
|
||||
|
||||
options = _build_options(request)
|
||||
design = await asyncio.to_thread(
|
||||
run_section_generation,
|
||||
project_root,
|
||||
route_data_path,
|
||||
request.filter_key,
|
||||
request.method,
|
||||
request.smooth,
|
||||
options=options,
|
||||
crs=request.crs,
|
||||
)
|
||||
|
||||
await connection.begin()
|
||||
try:
|
||||
await delete_sections_for_route(connection, request.route_id)
|
||||
longitudinal_id = await create_longitudinal_section(
|
||||
connection,
|
||||
project_id=project_id,
|
||||
route_id=request.route_id,
|
||||
data=design["longitudinal"]["data"],
|
||||
longitudinal_file_path=design["longitudinal"]["file_path"],
|
||||
)
|
||||
await insert_cross_sections(
|
||||
connection,
|
||||
project_id=project_id,
|
||||
route_id=request.route_id,
|
||||
sections=design["cross_sections"],
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
|
||||
return SectionGenerateResponse(
|
||||
project_id=str(project_id),
|
||||
route_id=request.route_id,
|
||||
longitudinal_id=longitudinal_id,
|
||||
cross_section_count=len(design["cross_sections"]),
|
||||
length_m=design["longitudinal"]["data"]["length_m"],
|
||||
longitudinal_file_path=design["longitudinal"]["file_path"],
|
||||
)
|
||||
except LookupError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except FileNotFoundError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except (OSError, ValueError) as exc:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception("B06 종횡단 생성 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "종횡단 생성 처리 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/sections/{route_id}", response_model=SectionSummaryResponse)
|
||||
async def get_sections(project_id: UUID, route_id: int) -> SectionSummaryResponse | JSONResponse:
|
||||
"""경로의 종단면 요약을 조회한다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
longitudinal = await get_longitudinal_section(connection, project_id, route_id)
|
||||
return SectionSummaryResponse(
|
||||
project_id=str(project_id), route_id=route_id, longitudinal=longitudinal
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("B06 종횡단 조회 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "종횡단 조회 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/sections/{route_id}/confirm", response_model=SectionConfirmResponse)
|
||||
async def confirm_sections(project_id: UUID, route_id: int) -> SectionConfirmResponse | JSONResponse:
|
||||
"""경로의 종횡단면을 확정(CONFIRMED)한다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
existing = await get_longitudinal_section(connection, project_id, route_id)
|
||||
if not existing:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "확정할 종횡단이 없습니다."},
|
||||
)
|
||||
await connection.begin()
|
||||
try:
|
||||
await confirm_sections_for_route(connection, route_id)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
return SectionConfirmResponse(project_id=str(project_id), route_id=route_id)
|
||||
except Exception:
|
||||
logger.exception("B06 종횡단 확정 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "종횡단 확정 처리 중 오류가 발생했습니다."},
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""B06 종횡단 생성 요청·응답 검증 모델."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class SectionGenerateRequest(BaseModel):
|
||||
"""종횡단 생성 실행 요청."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
route_id: int = Field(gt=0, description="종횡단을 생성할 확정 경로 routes.id")
|
||||
filter_key: str = Field(description="지면 필터 키 (grid_min_z/csf/pmf)")
|
||||
method: str = Field(default="dtm", description="지표면 표현")
|
||||
smooth: bool = Field(default=False)
|
||||
crs: str | None = Field(default=None, description="좌표계 (예: EPSG:5178)")
|
||||
|
||||
# 측점/횡단 옵션 (미지정 시 config 기본값)
|
||||
station_interval_m: float | None = Field(default=None, gt=0)
|
||||
cross_half_width_m: float | None = Field(default=None, gt=0)
|
||||
cross_sample_interval_m: float | None = Field(default=None, gt=0)
|
||||
long_sample_interval_m: float | None = Field(default=None, gt=0)
|
||||
|
||||
|
||||
class SectionGenerateResponse(BaseModel):
|
||||
"""종횡단 생성 결과."""
|
||||
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
route_id: int
|
||||
longitudinal_id: int
|
||||
cross_section_count: int
|
||||
length_m: float
|
||||
longitudinal_file_path: str
|
||||
|
||||
|
||||
class SectionConfirmResponse(BaseModel):
|
||||
"""종횡단 확정 결과."""
|
||||
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
route_id: int
|
||||
confirmed: bool = True
|
||||
|
||||
|
||||
class SectionSummaryResponse(BaseModel):
|
||||
"""종횡단 요약 조회 결과."""
|
||||
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
route_id: int
|
||||
longitudinal: dict[str, Any] | None = None
|
||||
@@ -0,0 +1,55 @@
|
||||
"""바이너리·npz 파일을 원자적으로 저장하는 공통 유틸리티."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def atomic_write_bytes(path: str | Path, payload: bytes) -> None:
|
||||
"""같은 디렉터리의 임시 파일을 교체하여 바이트를 원자적으로 저장한다."""
|
||||
target = Path(path)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary_path: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="wb",
|
||||
dir=target.parent,
|
||||
prefix=f".{target.name}.",
|
||||
suffix=".tmp",
|
||||
delete=False,
|
||||
) as temporary:
|
||||
temporary.write(payload)
|
||||
temporary.flush()
|
||||
os.fsync(temporary.fileno())
|
||||
temporary_path = Path(temporary.name)
|
||||
os.replace(temporary_path, target)
|
||||
temporary_path = None
|
||||
finally:
|
||||
if temporary_path is not None:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def atomic_write_npz(path: str | Path, **arrays: np.ndarray) -> None:
|
||||
"""같은 디렉터리의 임시 파일을 교체하여 압축 npz를 원자적으로 저장한다."""
|
||||
target = Path(path)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary_path: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="wb",
|
||||
dir=target.parent,
|
||||
prefix=f".{target.name}.",
|
||||
suffix=".tmp",
|
||||
delete=False,
|
||||
) as temporary:
|
||||
np.savez_compressed(temporary, **arrays)
|
||||
temporary.flush()
|
||||
os.fsync(temporary.fileno())
|
||||
temporary_path = Path(temporary.name)
|
||||
os.replace(temporary_path, target)
|
||||
temporary_path = None
|
||||
finally:
|
||||
if temporary_path is not None:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""JSON 파일을 안전하게 저장하는 공통 유틸리티."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def atomic_write_json(path: str | Path, value: Any) -> None:
|
||||
"""같은 디렉터리의 임시 파일을 교체하여 JSON을 원자적으로 저장한다."""
|
||||
target = Path(path)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary_path: Path | None = None
|
||||
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
dir=target.parent,
|
||||
prefix=f".{target.name}.",
|
||||
suffix=".tmp",
|
||||
delete=False,
|
||||
) as temporary:
|
||||
json.dump(value, temporary, ensure_ascii=False, indent=2)
|
||||
temporary.write("\n")
|
||||
temporary.flush()
|
||||
os.fsync(temporary.fileno())
|
||||
temporary_path = Path(temporary.name)
|
||||
|
||||
os.replace(temporary_path, target)
|
||||
temporary_path = None
|
||||
finally:
|
||||
if temporary_path is not None:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""프로젝트 영구저장소 경로 유틸리티."""
|
||||
|
||||
import os
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
from config.config_system import PROJECT_STORAGE_STAGE_DIRS, STORAGE_BASE_DIR
|
||||
|
||||
|
||||
def get_project_stage_path(project_root: str, stage: str) -> str:
|
||||
"""프로젝트 루트 아래의 허용된 워크플로우 단계 폴더를 생성한다."""
|
||||
if stage not in PROJECT_STORAGE_STAGE_DIRS:
|
||||
raise ValueError(f"허용되지 않은 프로젝트 저장 단계입니다: {stage}")
|
||||
|
||||
root = os.path.abspath(project_root)
|
||||
path = os.path.abspath(os.path.join(root, stage))
|
||||
if os.path.commonpath((root, path)) != root:
|
||||
raise ValueError("단계 저장 경로가 프로젝트 루트를 벗어났습니다.")
|
||||
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def resolve_stored_project_path(relative_path: str) -> str:
|
||||
"""DB의 storage 기준 상대 경로를 검증해 실제 프로젝트 경로로 변환한다."""
|
||||
normalized = PurePosixPath(relative_path.replace("\\", "/"))
|
||||
if normalized.is_absolute() or ".." in normalized.parts:
|
||||
raise ValueError("프로젝트 저장 경로는 안전한 상대 경로여야 합니다.")
|
||||
if not normalized.parts or normalized.parts[0] != "storage":
|
||||
raise ValueError("프로젝트 저장 경로는 storage/로 시작해야 합니다.")
|
||||
|
||||
storage_root = os.path.abspath(STORAGE_BASE_DIR)
|
||||
path = os.path.abspath(os.path.join(storage_root, *normalized.parts[1:]))
|
||||
if os.path.commonpath((storage_root, path)) != storage_root or path == storage_root:
|
||||
raise ValueError("프로젝트 저장 경로가 저장소 루트를 벗어났습니다.")
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
@@ -0,0 +1,42 @@
|
||||
"""프로젝트 간 공유되는 워크플로우 상태 유틸리티."""
|
||||
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
|
||||
_WORKFLOW_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def load_project_workflow(project_root: str | Path) -> dict[str, Any]:
|
||||
"""프로젝트 루트의 workflow.json을 읽고 없으면 초기 상태를 반환한다."""
|
||||
workflow_path = Path(project_root) / "workflow.json"
|
||||
if not workflow_path.exists():
|
||||
return {
|
||||
"current_stage": "scan",
|
||||
"completed": [],
|
||||
"stale_from": None,
|
||||
"stage1_confirmed": None,
|
||||
}
|
||||
|
||||
with workflow_path.open("r", encoding="utf-8") as workflow_file:
|
||||
workflow = json.load(workflow_file)
|
||||
if not isinstance(workflow, dict):
|
||||
raise ValueError("workflow.json의 최상위 값은 객체여야 합니다.")
|
||||
return workflow
|
||||
|
||||
|
||||
def patch_project_workflow_stale(project_root: str | Path, stale_from: str | None) -> None:
|
||||
"""기존 workflow.json의 다른 상태를 보존하며 stale_from만 갱신한다."""
|
||||
workflow_path = Path(project_root) / "workflow.json"
|
||||
if not workflow_path.exists():
|
||||
return
|
||||
|
||||
with _WORKFLOW_LOCK:
|
||||
workflow = load_project_workflow(project_root)
|
||||
if workflow.get("stale_from") == stale_from:
|
||||
return
|
||||
workflow["stale_from"] = stale_from
|
||||
atomic_write_json(workflow_path, workflow)
|
||||
+20
-15
@@ -1,50 +1,55 @@
|
||||
"""
|
||||
config_db.py
|
||||
데이터베이스 연결 설정 (PostgreSQL + asyncpg)
|
||||
데이터베이스 연결 설정 (MariaDB + aiomysql)
|
||||
|
||||
비동기 연결 풀 생성 및 관리.
|
||||
"""
|
||||
|
||||
import asyncpg
|
||||
from typing import Optional
|
||||
|
||||
import aiomysql
|
||||
|
||||
from .config_system import (
|
||||
DB_HOST,
|
||||
DB_PORT,
|
||||
DB_NAME,
|
||||
DB_USER,
|
||||
DB_PASSWORD,
|
||||
DB_POOL_MIN,
|
||||
DB_POOL_MAX,
|
||||
DB_POOL_MIN,
|
||||
DB_PORT,
|
||||
DB_USER,
|
||||
)
|
||||
|
||||
# 글로벌 DB 풀 (앱 시작/종료 시 관리)
|
||||
db_pool: Optional[asyncpg.Pool] = None
|
||||
db_pool: Optional[aiomysql.Pool] = None
|
||||
|
||||
|
||||
async def init_db_pool() -> asyncpg.Pool:
|
||||
"""PostgreSQL 연결 풀 초기화"""
|
||||
async def init_db_pool() -> aiomysql.Pool:
|
||||
"""MariaDB 연결 풀 초기화"""
|
||||
global db_pool
|
||||
db_pool = await asyncpg.create_pool(
|
||||
db_pool = await aiomysql.create_pool(
|
||||
host=DB_HOST,
|
||||
port=DB_PORT,
|
||||
database=DB_NAME,
|
||||
db=DB_NAME,
|
||||
user=DB_USER,
|
||||
password=DB_PASSWORD,
|
||||
min_size=DB_POOL_MIN,
|
||||
max_size=DB_POOL_MAX,
|
||||
minsize=DB_POOL_MIN,
|
||||
maxsize=DB_POOL_MAX,
|
||||
autocommit=False,
|
||||
charset="utf8mb4",
|
||||
)
|
||||
return db_pool
|
||||
|
||||
|
||||
async def close_db_pool() -> None:
|
||||
"""PostgreSQL 연결 풀 종료"""
|
||||
"""MariaDB 연결 풀 종료"""
|
||||
global db_pool
|
||||
if db_pool:
|
||||
await db_pool.close()
|
||||
db_pool.close()
|
||||
await db_pool.wait_closed()
|
||||
db_pool = None
|
||||
|
||||
|
||||
def get_db_pool() -> asyncpg.Pool:
|
||||
def get_db_pool() -> aiomysql.Pool:
|
||||
"""현재 활성 DB 풀 반환. 없으면 RuntimeError"""
|
||||
if not db_pool:
|
||||
raise RuntimeError("DB pool not initialized. Call init_db_pool() first.")
|
||||
|
||||
@@ -19,12 +19,18 @@ export const API_TIMEOUT_MS = 30_000;
|
||||
/** 인증 토큰을 저장할 localStorage 키 */
|
||||
export const AUTH_TOKEN_KEY = "frd_auth_token";
|
||||
|
||||
/** B03~B09 워크플로우에서 사용할 현재 프로젝트 UUID 저장 키 */
|
||||
export const CURRENT_PROJECT_ID_KEY = "frd_current_project_id";
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* 2. 파일 업로드 제한 (B03_FileInput)
|
||||
* -------------------------------------------------------------------------- */
|
||||
/** 단일 파일 최대 크기 (MB) */
|
||||
export const UPLOAD_MAX_MB = 500;
|
||||
|
||||
/** 한 요청에서 선택 가능한 최대 파일 수 */
|
||||
export const UPLOAD_MAX_FILES = 20;
|
||||
|
||||
/** 허용 확장자 (지형/포인트클라우드/도면) */
|
||||
export const UPLOAD_ALLOWED_EXT = [
|
||||
".las",
|
||||
|
||||
+209
-10
@@ -6,6 +6,7 @@ config_system.py
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
@@ -27,15 +28,15 @@ STATIC_URL = "/static"
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 3. 데이터베이스 (PostgreSQL + PostGIS)
|
||||
# 3. 데이터베이스 (MariaDB)
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
DB_HOST = os.getenv("DB_HOST", "localhost")
|
||||
DB_PORT = int(os.getenv("DB_PORT", "5432"))
|
||||
DB_NAME = os.getenv("DB_NAME", "forest_road")
|
||||
DB_USER = os.getenv("DB_USER", "postgres")
|
||||
DB_PASSWORD = os.getenv("DB_PASSWORD", "postgres")
|
||||
DB_PORT = int(os.getenv("DB_PORT", "3306"))
|
||||
DB_NAME = os.getenv("DB_NAME", "aislo_db")
|
||||
DB_USER = os.getenv("DB_USER", "aislo")
|
||||
DB_PASSWORD = os.getenv("DB_PASSWORD", "aislo")
|
||||
|
||||
# asyncpg 연결 풀 설정
|
||||
# asyncmy 연결 풀 설정
|
||||
DB_POOL_MIN = int(os.getenv("DB_POOL_MIN", "5"))
|
||||
DB_POOL_MAX = int(os.getenv("DB_POOL_MAX", "20"))
|
||||
|
||||
@@ -43,6 +44,8 @@ DB_POOL_MAX = int(os.getenv("DB_POOL_MAX", "20"))
|
||||
# 4. 파일 업로드 제한
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
UPLOAD_MAX_MB = int(os.getenv("UPLOAD_MAX_MB", "500"))
|
||||
UPLOAD_MAX_FILES = int(os.getenv("UPLOAD_MAX_FILES", "20"))
|
||||
UPLOAD_CHUNK_SIZE_BYTES = int(os.getenv("UPLOAD_CHUNK_SIZE_BYTES", str(1024 * 1024)))
|
||||
UPLOAD_ALLOWED_EXT = [".las", ".laz", ".dem", ".tif", ".tiff", ".tfw", ".prj", ".dxf", ".dwg"]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -51,22 +54,218 @@ UPLOAD_ALLOWED_EXT = [".las", ".laz", ".dem", ".tif", ".tiff", ".tfw", ".prj", "
|
||||
# Trimesh 메쉬 생성
|
||||
MESH_GRID_SIZE = float(os.getenv("MESH_GRID_SIZE", "1.0")) # 미터 단위
|
||||
MESH_SMOOTHING_ITERATIONS = int(os.getenv("MESH_SMOOTHING_ITERATIONS", "0"))
|
||||
SURFACE_LAS_CHUNK_SIZE = int(os.getenv("SURFACE_LAS_CHUNK_SIZE", "500000"))
|
||||
SURFACE_DEFAULT_RGB_VALUE = int(os.getenv("SURFACE_DEFAULT_RGB_VALUE", "128"))
|
||||
SURFACE_GRID_CELL_SIZE_M = float(os.getenv("SURFACE_GRID_CELL_SIZE_M", "2.0"))
|
||||
SURFACE_GRID_HEIGHT_THRESHOLD_M = float(os.getenv("SURFACE_GRID_HEIGHT_THRESHOLD_M", "1.5"))
|
||||
|
||||
# CSF (Cloth Simulation Filter) 지면 분류 파라미터
|
||||
SURFACE_CSF_CLOTH_RESOLUTION_M = float(os.getenv("SURFACE_CSF_CLOTH_RESOLUTION_M", "1.5"))
|
||||
SURFACE_CSF_RIGIDNESS = int(os.getenv("SURFACE_CSF_RIGIDNESS", "1"))
|
||||
SURFACE_CSF_TIME_STEP = float(os.getenv("SURFACE_CSF_TIME_STEP", "0.65"))
|
||||
SURFACE_CSF_CLASS_THRESHOLD_M = float(os.getenv("SURFACE_CSF_CLASS_THRESHOLD_M", "0.5"))
|
||||
SURFACE_CSF_ITERATIONS = int(os.getenv("SURFACE_CSF_ITERATIONS", "150"))
|
||||
SURFACE_CSF_SLOPE_SMOOTH = os.getenv("SURFACE_CSF_SLOPE_SMOOTH", "True").lower() == "true"
|
||||
SURFACE_CSF_SLOPE_SMOOTH_THRESHOLD_M = float(
|
||||
os.getenv("SURFACE_CSF_SLOPE_SMOOTH_THRESHOLD_M", "1.8")
|
||||
)
|
||||
|
||||
# PMF (Progressive Morphological Filter) 지면 분류 파라미터
|
||||
SURFACE_PMF_CELL_SIZE_M = float(os.getenv("SURFACE_PMF_CELL_SIZE_M", "2.0"))
|
||||
SURFACE_PMF_MAX_WINDOW_SIZE = int(os.getenv("SURFACE_PMF_MAX_WINDOW_SIZE", "40"))
|
||||
SURFACE_PMF_INITIAL_WINDOW_SIZE = int(os.getenv("SURFACE_PMF_INITIAL_WINDOW_SIZE", "3"))
|
||||
SURFACE_PMF_SLOPE = float(os.getenv("SURFACE_PMF_SLOPE", "1.0"))
|
||||
SURFACE_PMF_MAX_DISTANCE_M = float(os.getenv("SURFACE_PMF_MAX_DISTANCE_M", "2.5"))
|
||||
|
||||
# RANSAC (Local plane fitting) 지면 분류 파라미터
|
||||
SURFACE_RANSAC_DISTANCE_THRESHOLD_M = float(os.getenv("SURFACE_RANSAC_DISTANCE_THRESHOLD_M", "0.3"))
|
||||
SURFACE_RANSAC_N = int(os.getenv("SURFACE_RANSAC_N", "3"))
|
||||
SURFACE_RANSAC_ITERATIONS = int(os.getenv("SURFACE_RANSAC_ITERATIONS", "100"))
|
||||
SURFACE_RANSAC_LOCAL_GRID_SIZE_M = float(os.getenv("SURFACE_RANSAC_LOCAL_GRID_SIZE_M", "10.0"))
|
||||
SURFACE_RANSAC_SEED = int(os.getenv("SURFACE_RANSAC_SEED", "42"))
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 5-2. 지표면 모델 생성 파라미터 (TIN/DTM/NURBS/implicit/meshfree)
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 빌드 대상 지면 필터·표현 방식
|
||||
SURFACE_MODEL_SOURCE_FILTERS = tuple(
|
||||
os.getenv("SURFACE_MODEL_SOURCE_FILTERS", "grid_min_z,csf,pmf").split(",")
|
||||
)
|
||||
SURFACE_MODEL_PRECOMPUTE = tuple(
|
||||
os.getenv("SURFACE_MODEL_PRECOMPUTE", "tin,dtm,nurbs,implicit,meshfree").split(",")
|
||||
)
|
||||
SURFACE_MODEL_SMOOTHING_METHODS = tuple(
|
||||
os.getenv("SURFACE_MODEL_SMOOTHING_METHODS", "dtm,tin").split(",")
|
||||
)
|
||||
SURFACE_MODEL_SYNC_TIMEOUT_SECONDS = int(os.getenv("SURFACE_MODEL_SYNC_TIMEOUT_SECONDS", "0"))
|
||||
|
||||
# footprint(외곽) 산출
|
||||
SURFACE_FOOTPRINT_RESOLUTION_M = float(os.getenv("SURFACE_FOOTPRINT_RESOLUTION_M", "1.0"))
|
||||
SURFACE_FOOTPRINT_GAP_CLOSE_M = float(os.getenv("SURFACE_FOOTPRINT_GAP_CLOSE_M", "1.0"))
|
||||
SURFACE_BOUNDARY_INSET_M = float(os.getenv("SURFACE_BOUNDARY_INSET_M", "1.0"))
|
||||
SURFACE_KEEP_LARGEST_FOOTPRINT = (
|
||||
os.getenv("SURFACE_KEEP_LARGEST_FOOTPRINT", "True").lower() == "true"
|
||||
)
|
||||
SURFACE_TILE_SIZE_M = float(os.getenv("SURFACE_TILE_SIZE_M", "50.0"))
|
||||
SURFACE_MAX_PREVIEW_VERTICES = int(os.getenv("SURFACE_MAX_PREVIEW_VERTICES", "120000"))
|
||||
|
||||
# 표현별 파라미터
|
||||
SURFACE_TIN_MAX_INPUT_POINTS = int(os.getenv("SURFACE_TIN_MAX_INPUT_POINTS", "200000"))
|
||||
SURFACE_DTM_GRID_RESOLUTION_M = float(os.getenv("SURFACE_DTM_GRID_RESOLUTION_M", "1.0"))
|
||||
SURFACE_NURBS_DEGREE = int(os.getenv("SURFACE_NURBS_DEGREE", "3"))
|
||||
SURFACE_NURBS_PATCH_SIZE_M = float(os.getenv("SURFACE_NURBS_PATCH_SIZE_M", "50.0"))
|
||||
SURFACE_NURBS_CONTROL_POINTS_PER_AXIS = int(
|
||||
os.getenv("SURFACE_NURBS_CONTROL_POINTS_PER_AXIS", "16")
|
||||
)
|
||||
SURFACE_IMPLICIT_MAX_POINTS_PER_TILE = int(
|
||||
os.getenv("SURFACE_IMPLICIT_MAX_POINTS_PER_TILE", "20000")
|
||||
)
|
||||
SURFACE_IMPLICIT_SMOOTHING = float(os.getenv("SURFACE_IMPLICIT_SMOOTHING", "0.5"))
|
||||
SURFACE_MESHFREE_MAX_MODEL_POINTS = int(os.getenv("SURFACE_MESHFREE_MAX_MODEL_POINTS", "300000"))
|
||||
SURFACE_MESHFREE_POINT_RADIUS_M = float(os.getenv("SURFACE_MESHFREE_POINT_RADIUS_M", "0.5"))
|
||||
|
||||
# 스무딩 파라미터
|
||||
SURFACE_SMOOTHING_DTM_SIGMA_M = float(os.getenv("SURFACE_SMOOTHING_DTM_SIGMA_M", "0.5"))
|
||||
SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH = float(os.getenv("SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH", "0.0"))
|
||||
SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M = float(
|
||||
os.getenv("SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M", "0.5")
|
||||
)
|
||||
SURFACE_SMOOTHING_TIN_TAUBIN_ITERATIONS = int(
|
||||
os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_ITERATIONS", "10")
|
||||
)
|
||||
SURFACE_SMOOTHING_TIN_TAUBIN_LAMBDA = float(os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_LAMBDA", "0.5"))
|
||||
SURFACE_SMOOTHING_TIN_TAUBIN_MU = float(os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_MU", "-0.53"))
|
||||
|
||||
# 등고선 파라미터
|
||||
SURFACE_CONTOUR_INTERVAL_M = float(os.getenv("SURFACE_CONTOUR_INTERVAL_M", "5.0"))
|
||||
SURFACE_CONTOUR_GRID_RESOLUTION_M = float(os.getenv("SURFACE_CONTOUR_GRID_RESOLUTION_M", "1.0"))
|
||||
|
||||
|
||||
def build_surface_model_config() -> dict:
|
||||
"""지표면 모델 파이프라인이 사용하는 config dict를 조립한다."""
|
||||
return {
|
||||
"source_filters": list(SURFACE_MODEL_SOURCE_FILTERS),
|
||||
"precompute": list(SURFACE_MODEL_PRECOMPUTE),
|
||||
"smoothing_methods": tuple(SURFACE_MODEL_SMOOTHING_METHODS),
|
||||
"sync_timeout_seconds": SURFACE_MODEL_SYNC_TIMEOUT_SECONDS,
|
||||
"footprint_resolution_meters": SURFACE_FOOTPRINT_RESOLUTION_M,
|
||||
"footprint_gap_close_meters": SURFACE_FOOTPRINT_GAP_CLOSE_M,
|
||||
"boundary_inset_meters": SURFACE_BOUNDARY_INSET_M,
|
||||
"keep_largest_footprint": SURFACE_KEEP_LARGEST_FOOTPRINT,
|
||||
"tile_size_meters": SURFACE_TILE_SIZE_M,
|
||||
"max_preview_vertices": SURFACE_MAX_PREVIEW_VERTICES,
|
||||
"tin_max_input_points": SURFACE_TIN_MAX_INPUT_POINTS,
|
||||
"dtm_grid_resolution_meters": SURFACE_DTM_GRID_RESOLUTION_M,
|
||||
"nurbs_degree": SURFACE_NURBS_DEGREE,
|
||||
"nurbs_patch_size_meters": SURFACE_NURBS_PATCH_SIZE_M,
|
||||
"nurbs_control_points_per_axis": SURFACE_NURBS_CONTROL_POINTS_PER_AXIS,
|
||||
"implicit_max_points_per_tile": SURFACE_IMPLICIT_MAX_POINTS_PER_TILE,
|
||||
"implicit_smoothing": SURFACE_IMPLICIT_SMOOTHING,
|
||||
"meshfree_max_model_points": SURFACE_MESHFREE_MAX_MODEL_POINTS,
|
||||
"meshfree_point_radius_meters": SURFACE_MESHFREE_POINT_RADIUS_M,
|
||||
"smoothing_dtm_sigma_meters": SURFACE_SMOOTHING_DTM_SIGMA_M,
|
||||
"smoothing_dtm_spline_smooth": SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH,
|
||||
"smoothing_dtm_preview_resolution_meters": SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M,
|
||||
"smoothing_tin_taubin_iterations": SURFACE_SMOOTHING_TIN_TAUBIN_ITERATIONS,
|
||||
"smoothing_tin_taubin_lambda": SURFACE_SMOOTHING_TIN_TAUBIN_LAMBDA,
|
||||
"smoothing_tin_taubin_mu": SURFACE_SMOOTHING_TIN_TAUBIN_MU,
|
||||
"contour_interval_meters": SURFACE_CONTOUR_INTERVAL_M,
|
||||
"contour_grid_resolution_meters": SURFACE_CONTOUR_GRID_RESOLUTION_M,
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 5-3. 경로 설계 파라미터 (B05 WF2)
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 비용 함수 가중치
|
||||
ROUTE_W_DIST = float(os.getenv("ROUTE_W_DIST", "1.0"))
|
||||
ROUTE_W_GRADE = float(os.getenv("ROUTE_W_GRADE", "2.0"))
|
||||
ROUTE_W_SIDE = float(os.getenv("ROUTE_W_SIDE", "1.5"))
|
||||
ROUTE_W_CURVE = float(os.getenv("ROUTE_W_CURVE", "0.5"))
|
||||
ROUTE_W_AVOID = float(os.getenv("ROUTE_W_AVOID", "10.0"))
|
||||
ROUTE_WEIGHT_MAX = float(os.getenv("ROUTE_WEIGHT_MAX", "1000.0"))
|
||||
|
||||
# 격자·경사·비용 제약
|
||||
ROUTE_MAX_GRADE = float(os.getenv("ROUTE_MAX_GRADE", "0.14"))
|
||||
ROUTE_MAX_GRADE_PAVED = float(os.getenv("ROUTE_MAX_GRADE_PAVED", "0.18"))
|
||||
ROUTE_GRID_RES_M = float(os.getenv("ROUTE_GRID_RES_M", "2.0"))
|
||||
ROUTE_AVOID_DEFAULT_RADIUS_M = float(os.getenv("ROUTE_AVOID_DEFAULT_RADIUS_M", "25.0"))
|
||||
ROUTE_DEFAULT_GRADE_CLASS = os.getenv("ROUTE_DEFAULT_GRADE_CLASS", "trunk")
|
||||
ROUTE_MAX_COST_CELLS = int(os.getenv("ROUTE_MAX_COST_CELLS", "4000000"))
|
||||
ROUTE_REQUIRED_POINT_TOLERANCE_M = float(os.getenv("ROUTE_REQUIRED_POINT_TOLERANCE_M", "1.0"))
|
||||
ROUTE_GRADE_CLASSES = ("trunk", "branch", "work")
|
||||
|
||||
# 임도 등급별 종단경사 한계 및 최소 곡선반지름
|
||||
FOREST_ROAD_MAX_GRADE = {"trunk": 0.26, "branch": 0.28, "work": 0.40}
|
||||
FOREST_ROAD_MIN_CURVE_R_M = {"trunk": 12.0, "branch": 10.0, "work": 6.0}
|
||||
|
||||
# 대안(정속경사) 파라미터
|
||||
ROUTE_ALT_MIN_GRADE = float(os.getenv("ROUTE_ALT_MIN_GRADE", "0.08"))
|
||||
ROUTE_ALT_MAX_GRADE = float(os.getenv("ROUTE_ALT_MAX_GRADE", "0.14"))
|
||||
ROUTE_ALT_GRADE_TOLERANCE = float(os.getenv("ROUTE_ALT_GRADE_TOLERANCE", "0.005"))
|
||||
|
||||
# 지형 skeleton (능선/계곡) 파라미터
|
||||
SKELETON_VALLEY_ACC_THRESHOLD_CELLS = int(os.getenv("SKELETON_VALLEY_ACC_THRESHOLD_CELLS", "500"))
|
||||
SKELETON_MAIN_VALLEY_ACC_THRESHOLD_CELLS = int(
|
||||
os.getenv("SKELETON_MAIN_VALLEY_ACC_THRESHOLD_CELLS", "5000")
|
||||
)
|
||||
SKELETON_RIDGE_ACC_THRESHOLD_CELLS = int(os.getenv("SKELETON_RIDGE_ACC_THRESHOLD_CELLS", "500"))
|
||||
SKELETON_MAIN_RIDGE_ACC_THRESHOLD_CELLS = int(
|
||||
os.getenv("SKELETON_MAIN_RIDGE_ACC_THRESHOLD_CELLS", "5000")
|
||||
)
|
||||
SKELETON_NODE_SPACING_M = float(os.getenv("SKELETON_NODE_SPACING_M", "10.0"))
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 5-4. 종횡단 생성 파라미터 (B06 WF3)
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
SECTION_STATION_INTERVAL_M = float(os.getenv("SECTION_STATION_INTERVAL_M", "20.0"))
|
||||
SECTION_CROSS_HALF_WIDTH_M = float(os.getenv("SECTION_CROSS_HALF_WIDTH_M", "15.0"))
|
||||
SECTION_CROSS_SAMPLE_INTERVAL_M = float(os.getenv("SECTION_CROSS_SAMPLE_INTERVAL_M", "0.5"))
|
||||
SECTION_LONG_SAMPLE_INTERVAL_M = float(os.getenv("SECTION_LONG_SAMPLE_INTERVAL_M", "1.0"))
|
||||
SECTION_INCLUDE_ENDPOINT = os.getenv("SECTION_INCLUDE_ENDPOINT", "True").lower() == "true"
|
||||
|
||||
# PostGIS 공간 연산
|
||||
SPATIAL_INDEX_ENABLED = os.getenv("SPATIAL_INDEX_ENABLED", "True").lower() == "true"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 6. 저장소 경로
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
STORAGE_BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "storage")
|
||||
PROJECT_STORAGE_STAGE_DIRS = frozenset(
|
||||
{
|
||||
"B03_FileInput",
|
||||
"B04_wf1_Surface",
|
||||
"B05_wf2_Route",
|
||||
"B06_wf3_ProfileCross",
|
||||
"B07_wf4_DesignDetail",
|
||||
"B08_wf5_Quantity",
|
||||
"B09_wf6_Estimation",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# 프로젝트별 저장 구조: storage/[고객사]/[사용자]/[프로젝트ID]/
|
||||
def get_project_storage_path(company: str, user: str, project_id: str) -> str:
|
||||
"""프로젝트 저장 경로 생성 (자동 생성)"""
|
||||
path = os.path.join(STORAGE_BASE_DIR, "projects", company, user, project_id)
|
||||
"""검증된 식별자로 프로젝트 저장 경로를 생성한다."""
|
||||
segments = (company, user, project_id)
|
||||
for segment in segments:
|
||||
if (
|
||||
not segment
|
||||
or segment in {".", ".."}
|
||||
or os.path.isabs(segment)
|
||||
or "/" in segment
|
||||
or "\\" in segment
|
||||
):
|
||||
raise ValueError("저장 경로 식별자는 비어 있거나 경로 구분자를 포함할 수 없습니다.")
|
||||
|
||||
storage_root = os.path.abspath(STORAGE_BASE_DIR)
|
||||
path = os.path.abspath(os.path.join(storage_root, *segments))
|
||||
if os.path.commonpath((storage_root, path)) != storage_root:
|
||||
raise ValueError("프로젝트 저장 경로가 저장소 루트를 벗어났습니다.")
|
||||
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 7. 인증 (JWT)
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
-- =============================================================================
|
||||
-- MariaDB 데이터베이스 스키마 생성 SQL (오류 수정 완)
|
||||
-- Aislo (아이슬로) — AI + Slotti 산림 도로 설계 자동화 시스템
|
||||
--
|
||||
-- DB 명: aislo_db
|
||||
-- DBMS: MariaDB 10.6+
|
||||
-- 실행 순서:
|
||||
-- 1. DATABASE 생성 (aislo_db)
|
||||
-- 2. CREATE TABLE (각 테이블)
|
||||
-- 3. CREATE INDEX (인덱싱)
|
||||
-- 4. ALTER TABLE ADD CONSTRAINT (외래키)
|
||||
-- =============================================================================
|
||||
|
||||
-- 0. 데이터베이스 생성
|
||||
-- =============================================================================
|
||||
CREATE DATABASE IF NOT EXISTS aislo_db
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE aislo_db;
|
||||
|
||||
|
||||
-- 1. 테이블 생성 (순서 중요: FK 참조 테이블이 먼저 생성되어야 함)
|
||||
-- =============================================================================
|
||||
|
||||
/* --------- 1-1. 사용자 & 인증 그룹 --------- */
|
||||
|
||||
-- users: 시스템 사용자
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
position VARCHAR(100), -- 직급 (과장, 대리, 사원)
|
||||
department VARCHAR(100), -- 부서명 (설계팀, 영업팀)
|
||||
phone VARCHAR(20), -- 연락처 (010-1234-5678)
|
||||
company_id INT, -- 소속 회사 (FK는 later)
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL DEFAULT NULL -- 소프트 삭제
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- companies: 회사 정보
|
||||
CREATE TABLE IF NOT EXISTS companies (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
business_registration_number VARCHAR(20) UNIQUE, -- 사업자등록번호
|
||||
business_address VARCHAR(255), -- 사업장 주소
|
||||
business_owner VARCHAR(100), -- 사업주 이름
|
||||
business_status VARCHAR(50), -- 기업 상태 (활동중, 폐업, 휴업)
|
||||
created_by INT, -- 생성자 (FK는 later)
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL DEFAULT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
|
||||
/* --------- 1-2. 프로젝트 그룹 --------- */
|
||||
|
||||
-- projects: 설계 프로젝트
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id CHAR(36) PRIMARY KEY COMMENT 'UUID',
|
||||
user_id INT NOT NULL, -- 프로젝트 소유자 (FK는 later)
|
||||
company_id INT NOT NULL, -- 소속 회사 (FK는 later)
|
||||
name VARCHAR(255) NOT NULL,
|
||||
region VARCHAR(100), -- 지역명 (울진군 금강송면)
|
||||
road_type VARCHAR(100), -- 임도 종류 (간선임도, 지선임도, 산불진화임도, 계류보전)
|
||||
project_year INT, -- 사업 연도
|
||||
estimated_length_m FLOAT, -- 추정 연장 (m)
|
||||
memo TEXT,
|
||||
status VARCHAR(50) DEFAULT 'NEW', -- NEW, FILE_UPLOADED, WF1_ANALYZING, ..., DONE
|
||||
crs_epsg INT DEFAULT 5178, -- 좌표계 (한국 표준)
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||
storage_path VARCHAR(500) -- storage/회사/사용자/project_uuid
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- project_versions: 프로젝트 버전 이력
|
||||
CREATE TABLE IF NOT EXISTS project_versions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
project_id CHAR(36) NOT NULL, -- FK는 later
|
||||
version_num INT NOT NULL,
|
||||
snapshot_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
status VARCHAR(50),
|
||||
data JSON, -- 설계 데이터 스냅샷
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
|
||||
/* --------- 1-3. 파일 & 입력 데이터 그룹 --------- */
|
||||
|
||||
-- input_files: 사용자 업로드 원본 파일
|
||||
CREATE TABLE IF NOT EXISTS input_files (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
project_id CHAR(36) NOT NULL, -- FK는 later
|
||||
file_type VARCHAR(50), -- las, tif, tfw, prj, dxf, dwg, other
|
||||
original_filename VARCHAR(255) NOT NULL,
|
||||
raw_file_path VARCHAR(500) NOT NULL, -- storage/.../B03_FileInput/input/...
|
||||
file_size_mb FLOAT,
|
||||
upload_by INT, -- FK는 later
|
||||
upload_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
crs_epsg INT,
|
||||
metadata JSON, -- resolution_m, data_range, ...
|
||||
status VARCHAR(50) DEFAULT 'UPLOADED' -- UPLOADED, PROCESSED, ARCHIVED
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- processed_point_cloud: 변환된 포인트클라우드 데이터
|
||||
CREATE TABLE IF NOT EXISTS processed_point_cloud (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
input_file_id INT NOT NULL, -- FK는 later
|
||||
project_id CHAR(36) NOT NULL, -- FK는 later
|
||||
process_type VARCHAR(50), -- filtered, sampled, classified
|
||||
processed_file_path VARCHAR(500), -- storage/.../B04_wf1_Surface/processed/...
|
||||
converted_format VARCHAR(50), -- ply, laz, las 등
|
||||
converted_file_path VARCHAR(500), -- storage/.../B04_wf1_Surface/processed/...
|
||||
point_count INT,
|
||||
min_z FLOAT,
|
||||
max_z FLOAT,
|
||||
mean_z FLOAT,
|
||||
x_min FLOAT,
|
||||
x_max FLOAT,
|
||||
y_min FLOAT,
|
||||
y_max FLOAT,
|
||||
density_per_sqm FLOAT,
|
||||
classification_summary JSON, -- ground, vegetation, building, ...
|
||||
processing_params JSON,
|
||||
processed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
status VARCHAR(50) DEFAULT 'PROCESSING' -- PROCESSING, COMPLETE, FAILED
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
|
||||
/* --------- 1-4. 지표면 & 분석 결과 그룹 --------- */
|
||||
|
||||
-- surface_models: WF1 지표면 분석 결과
|
||||
CREATE TABLE IF NOT EXISTS surface_models (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
project_id CHAR(36) NOT NULL, -- FK는 later
|
||||
model_type VARCHAR(50), -- dem_grid, tin, mesh_triangulated, contour_lines
|
||||
source_file_id INT, -- FK는 later
|
||||
processed_cloud_id INT, -- FK는 later
|
||||
status VARCHAR(50) DEFAULT 'PROCESSING', -- PROCESSING, COMPLETE, FAILED
|
||||
crs_epsg INT,
|
||||
resolution_m FLOAT,
|
||||
model_file_path VARCHAR(500), -- storage/.../B04_wf1_Surface/models/...
|
||||
generation_params JSON, -- filter_type, algorithm, params
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP NULL DEFAULT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- terrain_layers: WF1 지형 레이어
|
||||
CREATE TABLE IF NOT EXISTS terrain_layers (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
surface_model_id INT NOT NULL, -- FK는 later
|
||||
layer_name VARCHAR(100), -- 지표, 제1층, 제2층
|
||||
geometry_type VARCHAR(50), -- POINTCLOUD, GRID, MESH, CONTOUR
|
||||
layer_file_path VARCHAR(500), -- storage/.../B04_wf1_Surface/models/...
|
||||
file_format VARCHAR(50), -- geojson, geotiff, ply, obj
|
||||
file_size_mb FLOAT,
|
||||
statistics JSON, -- min_z, max_z, mean_slope, point_count
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
|
||||
/* --------- 1-5. 경로 & 설계 그룹 --------- */
|
||||
|
||||
-- routes: WF2 경로 설계 결과
|
||||
CREATE TABLE IF NOT EXISTS routes (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
project_id CHAR(36) NOT NULL, -- FK는 later
|
||||
surface_model_id INT, -- FK는 later
|
||||
status VARCHAR(50) DEFAULT 'DRAFT', -- DRAFT, CONFIRMED, ARCHIVED
|
||||
start_chainage_m FLOAT,
|
||||
end_chainage_m FLOAT,
|
||||
total_length_m FLOAT,
|
||||
grade_percent JSON, -- [2.5, 1.8, 3.2, ...] 각 구간 경사도
|
||||
constraints JSON, -- max_grade, min_radius, avoidance_zones
|
||||
algorithm_params JSON, -- cost_weights, ...
|
||||
route_data_path VARCHAR(500), -- storage/.../B05_wf2_Route/route/...
|
||||
computed_at TIMESTAMP NULL DEFAULT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- route_points: 웹 렌더링용 경로 포인트 샘플
|
||||
CREATE TABLE IF NOT EXISTS route_points (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
route_id INT NOT NULL, -- FK는 later
|
||||
chainage_m FLOAT,
|
||||
elevation_m FLOAT,
|
||||
slope_percent FLOAT,
|
||||
sequence_num INT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- route_statistics: 경로 통계
|
||||
CREATE TABLE IF NOT EXISTS route_statistics (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
route_id INT NOT NULL, -- FK는 later
|
||||
min_slope FLOAT,
|
||||
max_slope FLOAT,
|
||||
mean_slope FLOAT,
|
||||
cut_volume_m3 FLOAT,
|
||||
fill_volume_m3 FLOAT,
|
||||
tree_cutting_volume FLOAT,
|
||||
cost_score FLOAT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
|
||||
/* --------- 1-6. 종단면 & 횡단면 그룹 --------- */
|
||||
|
||||
-- longitudinal_sections: WF3 종단면
|
||||
CREATE TABLE IF NOT EXISTS longitudinal_sections (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
project_id CHAR(36) NOT NULL, -- FK는 later
|
||||
route_id INT NOT NULL, -- FK는 later
|
||||
computed_at TIMESTAMP NULL DEFAULT NULL,
|
||||
data JSON, -- chainages, elevations, grades, design_elevations
|
||||
longitudinal_file_path VARCHAR(500), -- storage/.../B06_wf3_ProfileCross/longitudinal/...
|
||||
status VARCHAR(50) DEFAULT 'DRAFT' -- DRAFT, CONFIRMED
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- cross_sections: WF3 횡단면
|
||||
CREATE TABLE IF NOT EXISTS cross_sections (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
project_id CHAR(36) NOT NULL, -- FK는 later
|
||||
route_id INT NOT NULL, -- FK는 later
|
||||
chainage_m FLOAT,
|
||||
sequence_num INT,
|
||||
data JSON, -- left_slope, right_slope, width_m, cut_volume_m3, fill_volume_m3, structures, notes
|
||||
cross_section_file_path VARCHAR(500), -- storage/.../B06_wf3_ProfileCross/cross_sections/...
|
||||
status VARCHAR(50) DEFAULT 'DRAFT' -- DRAFT, CONFIRMED
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
|
||||
/* --------- 1-7. 설계 & 구조물 그룹 --------- */
|
||||
|
||||
-- structures: WF4 상세 설계 - 구조물
|
||||
CREATE TABLE IF NOT EXISTS structures (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
project_id CHAR(36) NOT NULL, -- FK는 later
|
||||
cross_section_id INT, -- FK는 later
|
||||
structure_type VARCHAR(100), -- 낙석방지책, 돌붙임, 계간수로, 낙차공
|
||||
chainage_m FLOAT,
|
||||
location VARCHAR(50), -- LEFT, CENTER, RIGHT
|
||||
length_m FLOAT,
|
||||
width_m FLOAT,
|
||||
height_m FLOAT,
|
||||
material VARCHAR(100), -- 강재, 콘크리트, 목재, 돌
|
||||
quantity INT,
|
||||
unit_price FLOAT,
|
||||
design_notes JSON,
|
||||
structure_data_path VARCHAR(500), -- storage/.../B07_wf4_DesignDetail/structures/...
|
||||
last_modified_by INT, -- FK는 later
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
|
||||
/* --------- 1-8. 수량 산출 그룹 --------- */
|
||||
|
||||
-- quantity_items: WF5 수량 산출
|
||||
CREATE TABLE IF NOT EXISTS quantity_items (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
project_id CHAR(36) NOT NULL, -- FK는 later
|
||||
category VARCHAR(100), -- 토공, 구조물, 포장, 배수, 녹화, 안전시설
|
||||
item_name VARCHAR(255),
|
||||
unit VARCHAR(50), -- m3, 개, m, m2
|
||||
quantity_design FLOAT,
|
||||
quantity_actual FLOAT,
|
||||
unit_price FLOAT,
|
||||
total_price FLOAT, -- quantity_actual × unit_price
|
||||
standard_reference VARCHAR(255),
|
||||
computed_at TIMESTAMP NULL DEFAULT NULL,
|
||||
quantity_data_path VARCHAR(500), -- storage/.../B08_wf5_Quantity/quantities/...
|
||||
data JSON -- formula, source, ...
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
|
||||
/* --------- 1-9. 산출물 & 문서 그룹 --------- */
|
||||
|
||||
-- outputs: WF6 최종 산출물 세트
|
||||
CREATE TABLE IF NOT EXISTS outputs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
project_id CHAR(36) NOT NULL, -- FK는 later
|
||||
output_type VARCHAR(100), -- estimation_excel, drawing_dxf, report_pdf, all_bundle
|
||||
status VARCHAR(50) DEFAULT 'GENERATING', -- GENERATING, COMPLETE, FAILED
|
||||
generated_by INT, -- FK는 later
|
||||
generated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
version INT DEFAULT 1,
|
||||
outputs_directory_path VARCHAR(500), -- storage/.../B09_wf6_Estimation/v1/
|
||||
metadata JSON -- template_used, company_name, project_name, total_cost, generation_time_sec
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- output_files: 최종 산출 파일
|
||||
CREATE TABLE IF NOT EXISTS output_files (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
output_id INT NOT NULL, -- FK는 later
|
||||
file_type VARCHAR(50), -- xlsx, pdf, dxf, dwg, json, zip
|
||||
original_filename VARCHAR(255),
|
||||
output_file_path VARCHAR(500), -- storage/.../B09_wf6_Estimation/v1/...
|
||||
file_size_mb FLOAT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
download_count INT DEFAULT 0
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
|
||||
/* --------- 1-10. 변경 이력 & 감시 그룹 --------- */
|
||||
|
||||
-- audit_logs: 보안 감시 로그
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT, -- FK는 later
|
||||
action VARCHAR(100), -- CREATE, READ, UPDATE, DELETE, EXPORT, DOWNLOAD
|
||||
entity_type VARCHAR(100), -- projects, routes, structures, outputs
|
||||
entity_id VARCHAR(36),
|
||||
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ip_address VARCHAR(45),
|
||||
details JSON -- old_value, new_value, reason
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- change_logs: 설계 변경 이력
|
||||
-- [★오류 수정]: 외래키 ON DELETE SET NULL 조건을 위해 changed_by 컬럼을 NULL 가능으로 변경
|
||||
CREATE TABLE IF NOT EXISTS change_logs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
project_id CHAR(36) NOT NULL, -- FK는 later
|
||||
changed_by INT NULL, -- NOT NULL 에서 NULL로 변경 완료
|
||||
changed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
entity_type VARCHAR(100), -- routes, cross_sections, structures, quantity_items
|
||||
entity_id INT,
|
||||
old_value JSON,
|
||||
new_value JSON,
|
||||
reason VARCHAR(255)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. 외래키(FK) 제약조건 추가
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT fk_users_company_id
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE companies
|
||||
ADD CONSTRAINT fk_companies_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE projects
|
||||
ADD CONSTRAINT fk_projects_user_id
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT,
|
||||
ADD CONSTRAINT fk_projects_company_id
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE RESTRICT;
|
||||
|
||||
ALTER TABLE project_versions
|
||||
ADD CONSTRAINT fk_project_versions_project_id
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE input_files
|
||||
ADD CONSTRAINT fk_input_files_project_id
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
ADD CONSTRAINT fk_input_files_upload_by
|
||||
FOREIGN KEY (upload_by) REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE processed_point_cloud
|
||||
ADD CONSTRAINT fk_processed_point_cloud_input_file_id
|
||||
FOREIGN KEY (input_file_id) REFERENCES input_files(id) ON DELETE CASCADE,
|
||||
ADD CONSTRAINT fk_processed_point_cloud_project_id
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE surface_models
|
||||
ADD CONSTRAINT fk_surface_models_project_id
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
ADD CONSTRAINT fk_surface_models_source_file_id
|
||||
FOREIGN KEY (source_file_id) REFERENCES input_files(id) ON DELETE SET NULL,
|
||||
ADD CONSTRAINT fk_surface_models_processed_cloud_id
|
||||
FOREIGN KEY (processed_cloud_id) REFERENCES processed_point_cloud(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE terrain_layers
|
||||
ADD CONSTRAINT fk_terrain_layers_surface_model_id
|
||||
FOREIGN KEY (surface_model_id) REFERENCES surface_models(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE routes
|
||||
ADD CONSTRAINT fk_routes_project_id
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
ADD CONSTRAINT fk_routes_surface_model_id
|
||||
FOREIGN KEY (surface_model_id) REFERENCES surface_models(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE route_points
|
||||
ADD CONSTRAINT fk_route_points_route_id
|
||||
FOREIGN KEY (route_id) REFERENCES routes(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE route_statistics
|
||||
ADD CONSTRAINT fk_route_statistics_route_id
|
||||
FOREIGN KEY (route_id) REFERENCES routes(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE longitudinal_sections
|
||||
ADD CONSTRAINT fk_longitudinal_sections_project_id
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
ADD CONSTRAINT fk_longitudinal_sections_route_id
|
||||
FOREIGN KEY (route_id) REFERENCES routes(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE cross_sections
|
||||
ADD CONSTRAINT fk_cross_sections_project_id
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
ADD CONSTRAINT fk_cross_sections_route_id
|
||||
FOREIGN KEY (route_id) REFERENCES routes(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE structures
|
||||
ADD CONSTRAINT fk_structures_project_id
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
ADD CONSTRAINT fk_structures_cross_section_id
|
||||
FOREIGN KEY (cross_section_id) REFERENCES cross_sections(id) ON DELETE SET NULL,
|
||||
ADD CONSTRAINT fk_structures_last_modified_by
|
||||
FOREIGN KEY (last_modified_by) REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE quantity_items
|
||||
ADD CONSTRAINT fk_quantity_items_project_id
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE outputs
|
||||
ADD CONSTRAINT fk_outputs_project_id
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
ADD CONSTRAINT fk_outputs_generated_by
|
||||
FOREIGN KEY (generated_by) REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE output_files
|
||||
ADD CONSTRAINT fk_output_files_output_id
|
||||
FOREIGN KEY (output_id) REFERENCES outputs(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE audit_logs
|
||||
ADD CONSTRAINT fk_audit_logs_user_id
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE change_logs
|
||||
ADD CONSTRAINT fk_change_logs_project_id
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
ADD CONSTRAINT fk_change_logs_changed_by
|
||||
FOREIGN KEY (changed_by) REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. 인덱스 생성 (성능 최적화)
|
||||
-- =============================================================================
|
||||
|
||||
-- 사용자/회사 인덱싱
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
CREATE INDEX idx_users_company_id ON users(company_id);
|
||||
CREATE INDEX idx_companies_name ON companies(name);
|
||||
|
||||
-- 프로젝트 인덱싱
|
||||
CREATE INDEX idx_projects_user_id ON projects(user_id);
|
||||
CREATE INDEX idx_projects_company_id ON projects(company_id);
|
||||
CREATE INDEX idx_projects_status ON projects(status);
|
||||
CREATE INDEX idx_projects_created_at ON projects(created_at DESC);
|
||||
CREATE INDEX idx_projects_storage_path ON projects(storage_path);
|
||||
|
||||
-- 파일 인덱싱
|
||||
CREATE INDEX idx_input_files_project_id ON input_files(project_id);
|
||||
CREATE INDEX idx_input_files_file_type ON input_files(file_type);
|
||||
CREATE INDEX idx_input_files_raw_file_path ON input_files(raw_file_path);
|
||||
CREATE INDEX idx_processed_point_cloud_input_file_id ON processed_point_cloud(input_file_id);
|
||||
CREATE INDEX idx_processed_point_cloud_project_id ON processed_point_cloud(project_id);
|
||||
CREATE INDEX idx_processed_point_cloud_converted_file_path ON processed_point_cloud(converted_file_path);
|
||||
|
||||
-- 지표면 모델 인덱싱
|
||||
CREATE INDEX idx_surface_models_project_id ON surface_models(project_id);
|
||||
CREATE INDEX idx_surface_models_status ON surface_models(status);
|
||||
CREATE INDEX idx_surface_models_model_file_path ON surface_models(model_file_path);
|
||||
CREATE INDEX idx_terrain_layers_surface_model_id ON terrain_layers(surface_model_id);
|
||||
CREATE INDEX idx_terrain_layers_layer_file_path ON terrain_layers(layer_file_path);
|
||||
|
||||
-- 경로 인덱싱
|
||||
CREATE INDEX idx_routes_project_id ON routes(project_id);
|
||||
CREATE INDEX idx_routes_status ON routes(status);
|
||||
CREATE INDEX idx_routes_route_data_path ON routes(route_data_path);
|
||||
CREATE INDEX idx_route_points_route_id ON route_points(route_id);
|
||||
CREATE INDEX idx_route_statistics_route_id ON route_statistics(route_id);
|
||||
|
||||
-- 단면 인덱싱
|
||||
CREATE INDEX idx_longitudinal_sections_project_id ON longitudinal_sections(project_id);
|
||||
CREATE INDEX idx_longitudinal_sections_route_id ON longitudinal_sections(route_id);
|
||||
CREATE INDEX idx_longitudinal_sections_file_path ON longitudinal_sections(longitudinal_file_path);
|
||||
CREATE INDEX idx_cross_sections_project_id ON cross_sections(project_id);
|
||||
CREATE INDEX idx_cross_sections_route_id ON cross_sections(route_id);
|
||||
CREATE INDEX idx_cross_sections_chainage_m ON cross_sections(chainage_m);
|
||||
CREATE INDEX idx_cross_sections_file_path ON cross_sections(cross_section_file_path);
|
||||
|
||||
-- 구조물/수량 인덱싱
|
||||
CREATE INDEX idx_structures_project_id ON structures(project_id);
|
||||
CREATE INDEX idx_structures_cross_section_id ON structures(cross_section_id);
|
||||
CREATE INDEX idx_structures_file_path ON structures(structure_data_path);
|
||||
CREATE INDEX idx_quantity_items_project_id ON quantity_items(project_id);
|
||||
CREATE INDEX idx_quantity_items_category ON quantity_items(category);
|
||||
CREATE INDEX idx_quantity_items_file_path ON quantity_items(quantity_data_path);
|
||||
|
||||
-- 산출물 인덱싱
|
||||
CREATE INDEX idx_outputs_project_id ON outputs(project_id);
|
||||
CREATE INDEX INDEX idx_outputs_version ON outputs(version);
|
||||
CREATE INDEX idx_outputs_directory_path ON outputs(outputs_directory_path);
|
||||
CREATE INDEX idx_output_files_output_id ON output_files(output_id);
|
||||
CREATE INDEX idx_output_files_output_file_path ON output_files(output_file_path);
|
||||
|
||||
-- 감시/이력 인덱싱
|
||||
CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id);
|
||||
CREATE INDEX idx_audit_logs_timestamp ON audit_logs(timestamp DESC);
|
||||
CREATE INDEX idx_audit_logs_entity_type ON audit_logs(entity_type);
|
||||
CREATE INDEX idx_change_logs_project_id ON change_logs(project_id);
|
||||
CREATE INDEX idx_change_logs_changed_by ON change_logs(changed_by);
|
||||
CREATE INDEX idx_change_logs_changed_at ON change_logs(changed_at DESC);
|
||||
|
||||
-- =============================================================================
|
||||
-- 5. 생성 완료 메시지
|
||||
-- =============================================================================
|
||||
|
||||
-- 모든 테이블과 인덱스가 정상적으로 생성되었으면 아래의 SELECT를 실행하여 확인할 수 있습니다:
|
||||
-- SELECT table_name FROM information_schema.tables WHERE table_schema='public';
|
||||
-- SELECT indexname FROM pg_indexes WHERE schemaname='public';
|
||||
@@ -9,29 +9,33 @@ FastAPI 애플리케이션 진입점
|
||||
- 라우터 등록 (페이지별 API)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from B03_FileInput.B03_FileInput_Router import router as b03_file_input_router
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Router import router as b04_surface_router
|
||||
from B05_wf2_Route.B05_wf2_Route_Router import router as b05_route_router
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Router import router as b06_section_router
|
||||
from config.config_db import close_db_pool, init_db_pool
|
||||
|
||||
# 설정 import
|
||||
from config.config_system import (
|
||||
CORS_ORIGINS,
|
||||
DEBUG,
|
||||
ENVIRONMENT,
|
||||
LOG_LEVEL,
|
||||
SERVER_HOST,
|
||||
SERVER_PORT,
|
||||
DEBUG,
|
||||
STATIC_DIR,
|
||||
STATIC_URL,
|
||||
LOG_LEVEL,
|
||||
CORS_ORIGINS,
|
||||
ENVIRONMENT,
|
||||
)
|
||||
from config.config_db import init_db_pool, close_db_pool
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(level=getattr(logging, LOG_LEVEL))
|
||||
@@ -41,6 +45,7 @@ logger = logging.getLogger(__name__)
|
||||
# 프론트엔드 빌드 및 서빙 함수
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_frontend() -> bool:
|
||||
"""프론트엔드 빌드 (npm run build from config/)"""
|
||||
root_dir = Path(__file__).resolve().parent
|
||||
@@ -96,7 +101,7 @@ def serve_frontend_dev() -> None:
|
||||
shell=True,
|
||||
cwd=str(config_dir),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
logger.info("✓ 프론트엔드 개발 서버 백그라운드 실행")
|
||||
except Exception as e:
|
||||
@@ -192,6 +197,10 @@ async def health():
|
||||
# app.include_router(a01_router, prefix="/api/a01", tags=["A01_Home"])
|
||||
#
|
||||
# 나중에 각 페이지별 라우터가 구현되면 여기에 등록.
|
||||
app.include_router(b03_file_input_router)
|
||||
app.include_router(b04_surface_router)
|
||||
app.include_router(b05_route_router)
|
||||
app.include_router(b06_section_router)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 앱 실행
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn==0.24.0
|
||||
pydantic==2.5.0
|
||||
asyncpg==0.29.0
|
||||
aiomysql==0.2.0
|
||||
python-multipart==0.0.6
|
||||
python-dotenv==1.0.0
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import laspy
|
||||
import numpy as np
|
||||
import rasterio
|
||||
from pyproj import CRS
|
||||
from rasterio.transform import from_origin
|
||||
|
||||
from B03_FileInput.B03_FileInput_Engine_Analyze import (
|
||||
analyze_input_metadata,
|
||||
analyze_las_metadata,
|
||||
analyze_prj_metadata,
|
||||
analyze_tfw_metadata,
|
||||
analyze_tif_metadata,
|
||||
)
|
||||
|
||||
|
||||
class AnalyzeLasMetadataTest(unittest.TestCase):
|
||||
def test_analyze_las_metadata(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
source = Path(temporary_directory) / "sample.las"
|
||||
las = laspy.LasData(laspy.LasHeader(point_format=3, version="1.2"))
|
||||
las.x = np.array([100.0, 102.0])
|
||||
las.y = np.array([200.0, 204.0])
|
||||
las.z = np.array([10.0, 14.0])
|
||||
las.classification = np.array([2, 5], dtype=np.uint8)
|
||||
las.write(source)
|
||||
|
||||
metadata = analyze_las_metadata(source)
|
||||
|
||||
self.assertEqual(metadata["file"], "sample.las")
|
||||
self.assertEqual(metadata["point_count"], 2)
|
||||
self.assertEqual(metadata["bounds"]["x"], [100.0, 102.0])
|
||||
self.assertEqual(metadata["bounds"]["y"], [200.0, 204.0])
|
||||
self.assertEqual(metadata["bounds"]["z"], [10.0, 14.0])
|
||||
self.assertEqual(metadata["classification_summary"], {"2": 1, "5": 1})
|
||||
|
||||
def test_analyze_prj_metadata(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
source = Path(temporary_directory) / "result.prj"
|
||||
source.write_text(CRS.from_epsg(5179).to_wkt(), encoding="utf-8")
|
||||
|
||||
metadata = analyze_prj_metadata(source)
|
||||
|
||||
self.assertEqual(metadata["file"], "result.prj")
|
||||
self.assertEqual(metadata["epsg"], 5179)
|
||||
self.assertTrue(metadata["is_valid"])
|
||||
|
||||
source.write_text("invalid wkt", encoding="utf-8")
|
||||
invalid_metadata = analyze_prj_metadata(source)
|
||||
self.assertFalse(invalid_metadata["is_valid"])
|
||||
self.assertIn("error", invalid_metadata)
|
||||
|
||||
def test_analyze_tfw_metadata(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
source = Path(temporary_directory) / "result.tfw"
|
||||
source.write_text("0.5\n0\n0\n-0.5\n100\n200\n", encoding="utf-8")
|
||||
|
||||
metadata = analyze_tfw_metadata(source)
|
||||
|
||||
self.assertTrue(metadata["is_valid"])
|
||||
self.assertEqual(metadata["pixel_size_x"], 0.5)
|
||||
self.assertEqual(metadata["pixel_size_y"], -0.5)
|
||||
self.assertEqual(metadata["origin_x"], 100.0)
|
||||
self.assertEqual(metadata["origin_y"], 200.0)
|
||||
|
||||
source.write_text("nan\n0\n0\n-0.5\n100\n200\n", encoding="utf-8")
|
||||
with self.assertRaises(ValueError):
|
||||
analyze_tfw_metadata(source)
|
||||
|
||||
def test_analyze_tif_metadata(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
source = Path(temporary_directory) / "result.tif"
|
||||
with rasterio.open(
|
||||
source,
|
||||
mode="w",
|
||||
driver="GTiff",
|
||||
width=3,
|
||||
height=2,
|
||||
count=1,
|
||||
dtype="float32",
|
||||
crs="EPSG:5179",
|
||||
transform=from_origin(100, 200, 2, 2),
|
||||
nodata=-9999,
|
||||
) as dataset:
|
||||
dataset.write(np.ones((1, 2, 3), dtype=np.float32))
|
||||
|
||||
metadata = analyze_tif_metadata(source)
|
||||
|
||||
self.assertEqual(metadata["file"], "result.tif")
|
||||
self.assertEqual(metadata["width"], 3)
|
||||
self.assertEqual(metadata["height"], 2)
|
||||
self.assertEqual(metadata["count"], 1)
|
||||
self.assertEqual(metadata["epsg"], 5179)
|
||||
self.assertEqual(metadata["resolution"], [2.0, 2.0])
|
||||
self.assertEqual(metadata["likely_type"], "dem")
|
||||
|
||||
def test_analyze_input_metadata(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
source = Path(temporary_directory) / "drawing.dxf"
|
||||
source.write_bytes(b"0\nSECTION\n")
|
||||
|
||||
metadata = analyze_input_metadata(source)
|
||||
|
||||
self.assertEqual(
|
||||
metadata,
|
||||
{"file": "drawing.dxf", "extension": "dxf", "size_bytes": 10},
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from B03_FileInput.B03_FileInput_Engine import (
|
||||
resolve_upload_destination,
|
||||
save_upload_stream,
|
||||
)
|
||||
from B03_FileInput.B03_FileInput_Schema import FileUploadDescriptor
|
||||
|
||||
|
||||
class ResolveUploadDestinationTest(unittest.TestCase):
|
||||
def test_resolve_upload_destination(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
descriptor = FileUploadDescriptor(
|
||||
original_filename="cloud_merged.LAS",
|
||||
size_bytes=1024,
|
||||
)
|
||||
|
||||
destination = resolve_upload_destination(temporary_directory, descriptor)
|
||||
|
||||
self.assertEqual(
|
||||
destination,
|
||||
Path(temporary_directory).resolve()
|
||||
/ "B03_FileInput"
|
||||
/ "input"
|
||||
/ "las"
|
||||
/ "cloud_merged.LAS",
|
||||
)
|
||||
self.assertTrue(destination.parent.is_dir())
|
||||
|
||||
|
||||
class SaveUploadStreamTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_save_upload_stream(self) -> None:
|
||||
class FakeUpload:
|
||||
def __init__(self, chunks: list[bytes]) -> None:
|
||||
self.chunks = iter(chunks)
|
||||
|
||||
async def read(self, _size: int) -> bytes:
|
||||
return next(self.chunks, b"")
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
destination = Path(temporary_directory) / "cloud.las"
|
||||
upload = FakeUpload([b"abc", b"def"])
|
||||
|
||||
written_bytes = await save_upload_stream(upload, destination) # type: ignore[arg-type]
|
||||
|
||||
self.assertEqual(written_bytes, 6)
|
||||
self.assertEqual(destination.read_bytes(), b"abcdef")
|
||||
self.assertEqual(list(destination.parent.glob("*.upload")), [])
|
||||
|
||||
with patch("B03_FileInput.B03_FileInput_Engine.UPLOAD_MAX_MB", 0):
|
||||
with self.assertRaises(ValueError):
|
||||
await save_upload_stream( # type: ignore[arg-type]
|
||||
FakeUpload([b"too-large"]),
|
||||
destination,
|
||||
)
|
||||
@@ -0,0 +1,100 @@
|
||||
import json
|
||||
import unittest
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import (
|
||||
create_input_file,
|
||||
get_project_storage_relative_path,
|
||||
)
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
"""asyncmy 커서 흉내 (async context manager + execute/fetchone/lastrowid)."""
|
||||
|
||||
def __init__(self, *, lastrowid: int = 0, row: tuple[Any, ...] | None = None) -> None:
|
||||
self.query = ""
|
||||
self.arguments: tuple[Any, ...] = ()
|
||||
self.lastrowid = lastrowid
|
||||
self._row = row
|
||||
|
||||
async def __aenter__(self) -> "FakeCursor":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_exc: Any) -> None:
|
||||
return None
|
||||
|
||||
async def execute(self, query: str, arguments: tuple[Any, ...] | None = None) -> None:
|
||||
self.query = query
|
||||
self.arguments = arguments or ()
|
||||
|
||||
async def fetchone(self) -> tuple[Any, ...] | None:
|
||||
return self._row
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
"""asyncmy 커넥션 흉내 (cursor() 팩토리)."""
|
||||
|
||||
def __init__(self, cursor: FakeCursor) -> None:
|
||||
self._cursor = cursor
|
||||
|
||||
def cursor(self) -> FakeCursor:
|
||||
return self._cursor
|
||||
|
||||
|
||||
class CreateInputFileTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_create_input_file(self) -> None:
|
||||
cursor = FakeCursor(lastrowid=17)
|
||||
connection = FakeConnection(cursor)
|
||||
project_id = UUID("550e8400-e29b-41d4-a716-446655440000")
|
||||
metadata = {"point_count": 2, "crs": "EPSG:5179"}
|
||||
|
||||
input_file_id = await create_input_file( # type: ignore[arg-type]
|
||||
connection,
|
||||
project_id=project_id,
|
||||
file_type="las",
|
||||
original_filename="cloud.las",
|
||||
relative_path="B03_FileInput/input/las/cloud.las",
|
||||
file_size_bytes=1024 * 1024,
|
||||
upload_by=3,
|
||||
crs_epsg=5179,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
self.assertEqual(input_file_id, 17)
|
||||
self.assertIn("INSERT INTO input_files", cursor.query)
|
||||
self.assertEqual(cursor.arguments[0], str(project_id))
|
||||
self.assertEqual(cursor.arguments[3], "B03_FileInput/input/las/cloud.las")
|
||||
self.assertEqual(cursor.arguments[4], 1.0)
|
||||
self.assertEqual(json.loads(cursor.arguments[7]), metadata)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
await create_input_file( # type: ignore[arg-type]
|
||||
connection,
|
||||
project_id=project_id,
|
||||
file_type="las",
|
||||
original_filename="cloud.las",
|
||||
relative_path="../outside/cloud.las",
|
||||
file_size_bytes=1,
|
||||
upload_by=None,
|
||||
crs_epsg=None,
|
||||
metadata={},
|
||||
)
|
||||
|
||||
async def test_get_project_storage_relative_path(self) -> None:
|
||||
project_id = UUID("550e8400-e29b-41d4-a716-446655440000")
|
||||
|
||||
connection = FakeConnection(FakeCursor(row=("storage/company/user/project-id",)))
|
||||
relative_path = await get_project_storage_relative_path( # type: ignore[arg-type]
|
||||
connection,
|
||||
project_id,
|
||||
)
|
||||
self.assertEqual(relative_path, "storage/company/user/project-id")
|
||||
|
||||
for row in (None, ("../outside",), ("/absolute/path",)):
|
||||
with self.subTest(row=row):
|
||||
with self.assertRaises((LookupError, ValueError)):
|
||||
await get_project_storage_relative_path( # type: ignore[arg-type]
|
||||
FakeConnection(FakeCursor(row=row)),
|
||||
project_id,
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
from uuid import UUID
|
||||
|
||||
import laspy
|
||||
import numpy as np
|
||||
|
||||
from B03_FileInput.B03_FileInput_Router import upload_project_files
|
||||
from B03_FileInput.B03_FileInput_Schema import FileUploadResponse
|
||||
|
||||
|
||||
class UploadProjectFilesTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_upload_project_files(self) -> None:
|
||||
class AsyncContext:
|
||||
def __init__(self, value: Any = None) -> None:
|
||||
self.value = value
|
||||
|
||||
async def __aenter__(self) -> Any:
|
||||
return self.value
|
||||
|
||||
async def __aexit__(self, *_args: Any) -> None:
|
||||
return None
|
||||
|
||||
class FakeCursor:
|
||||
def __init__(self, connection: "FakeConnection") -> None:
|
||||
self.connection = connection
|
||||
self.lastrowid = 0
|
||||
|
||||
async def __aenter__(self) -> "FakeCursor":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args: Any) -> None:
|
||||
return None
|
||||
|
||||
async def execute(self, query: str, _arguments: Any = None) -> None:
|
||||
if query.strip().upper().startswith("SELECT"):
|
||||
self._row: tuple[Any, ...] | None = ("storage/company/user/project-id",)
|
||||
else:
|
||||
self.lastrowid = self.connection.next_id
|
||||
self.connection.next_id += 1
|
||||
self._row = None
|
||||
|
||||
async def fetchone(self) -> tuple[Any, ...] | None:
|
||||
return getattr(self, "_row", None)
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self) -> None:
|
||||
self.next_id = 1
|
||||
self.committed = False
|
||||
self.rolled_back = False
|
||||
|
||||
def cursor(self) -> FakeCursor:
|
||||
return FakeCursor(self)
|
||||
|
||||
async def begin(self) -> None:
|
||||
return None
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.committed = True
|
||||
|
||||
async def rollback(self) -> None:
|
||||
self.rolled_back = True
|
||||
|
||||
class FakePool:
|
||||
def __init__(self, connection: FakeConnection) -> None:
|
||||
self.connection = connection
|
||||
|
||||
def acquire(self) -> AsyncContext:
|
||||
return AsyncContext(self.connection)
|
||||
|
||||
class FakeUpload:
|
||||
def __init__(self, filename: str, content: bytes) -> None:
|
||||
self.filename = filename
|
||||
self.size = len(content)
|
||||
self.file = io.BytesIO(content)
|
||||
self.closed = False
|
||||
|
||||
async def read(self, size: int) -> bytes:
|
||||
return self.file.read(size)
|
||||
|
||||
async def close(self) -> None:
|
||||
self.file.close()
|
||||
self.closed = True
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
project_root = Path(temporary_directory) / "project"
|
||||
project_root.mkdir()
|
||||
source = Path(temporary_directory) / "source.las"
|
||||
las = laspy.LasData(laspy.LasHeader(point_format=3, version="1.2"))
|
||||
las.x = np.array([1.0])
|
||||
las.y = np.array([2.0])
|
||||
las.z = np.array([3.0])
|
||||
las.write(source)
|
||||
upload = FakeUpload("cloud.las", source.read_bytes())
|
||||
pool = FakePool(FakeConnection())
|
||||
|
||||
with (
|
||||
patch("B03_FileInput.B03_FileInput_Router.get_db_pool", return_value=pool),
|
||||
patch(
|
||||
"B03_FileInput.B03_FileInput_Router.resolve_stored_project_path",
|
||||
return_value=str(project_root),
|
||||
),
|
||||
):
|
||||
response = await upload_project_files( # type: ignore[arg-type]
|
||||
UUID("550e8400-e29b-41d4-a716-446655440000"),
|
||||
[upload],
|
||||
)
|
||||
|
||||
self.assertIsInstance(response, FileUploadResponse)
|
||||
assert isinstance(response, FileUploadResponse)
|
||||
self.assertEqual(len(response.files), 1)
|
||||
self.assertEqual(response.files[0].input_file_id, 1)
|
||||
self.assertTrue(
|
||||
(project_root / "B03_FileInput" / "input" / "las" / "cloud.las").is_file()
|
||||
)
|
||||
self.assertTrue((project_root / "B03_FileInput" / "metadata.json").is_file())
|
||||
self.assertTrue((project_root / "workflow.json").is_file())
|
||||
self.assertTrue(upload.closed)
|
||||
@@ -0,0 +1,30 @@
|
||||
import unittest
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from B03_FileInput.B03_FileInput_Schema import FileUploadDescriptor
|
||||
from config.config_system import UPLOAD_MAX_MB
|
||||
|
||||
|
||||
class FileUploadDescriptorTest(unittest.TestCase):
|
||||
def test_file_upload_descriptor(self) -> None:
|
||||
descriptor = FileUploadDescriptor(
|
||||
original_filename="cloud_merged.LAS",
|
||||
size_bytes=1024,
|
||||
)
|
||||
self.assertEqual(descriptor.original_filename, "cloud_merged.LAS")
|
||||
|
||||
invalid_values = (
|
||||
{"original_filename": "../cloud.las", "size_bytes": 1024},
|
||||
{"original_filename": "nested/cloud.las", "size_bytes": 1024},
|
||||
{"original_filename": "cloud.exe", "size_bytes": 1024},
|
||||
{
|
||||
"original_filename": "cloud.las",
|
||||
"size_bytes": UPLOAD_MAX_MB * 1024 * 1024 + 1,
|
||||
},
|
||||
{"original_filename": "cloud.las", "size_bytes": 0},
|
||||
)
|
||||
for invalid_value in invalid_values:
|
||||
with self.subTest(invalid_value=invalid_value):
|
||||
with self.assertRaises(ValidationError):
|
||||
FileUploadDescriptor(**invalid_value)
|
||||
@@ -0,0 +1,64 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import laspy
|
||||
import numpy as np
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine import run_surface_analysis
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Ground import (
|
||||
build_ground_masks,
|
||||
summarize_masks,
|
||||
)
|
||||
|
||||
|
||||
class GroundMasksTest(unittest.TestCase):
|
||||
def test_build_and_summarize(self) -> None:
|
||||
coords = np.linspace(0.0, 20.0, 21)
|
||||
gx, gy = np.meshgrid(coords, coords)
|
||||
z = 5.0 + 0.03 * gx
|
||||
xyz = np.column_stack([gx.ravel(), gy.ravel(), z.ravel()]).astype(np.float64)
|
||||
bounds = np.array([[0.0, 20.0], [0.0, 20.0], [float(z.min()), float(z.max())]])
|
||||
data = {"xyz": xyz, "bounds": bounds}
|
||||
|
||||
masks = build_ground_masks(data, ["grid_min_z"])
|
||||
self.assertIn("grid_min_z", masks)
|
||||
self.assertEqual(masks["grid_min_z"].dtype, bool)
|
||||
|
||||
summary = summarize_masks(data, masks)
|
||||
self.assertEqual(summary["grid_min_z"]["total_point_count"], len(xyz))
|
||||
self.assertGreater(summary["grid_min_z"]["ground_ratio"], 0.9)
|
||||
|
||||
def test_unknown_filter(self) -> None:
|
||||
data = {"xyz": np.zeros((1, 3)), "bounds": np.zeros((3, 2))}
|
||||
with self.assertRaises(ValueError):
|
||||
build_ground_masks(data, ["unknown"])
|
||||
|
||||
|
||||
class RunSurfaceAnalysisTest(unittest.TestCase):
|
||||
def test_full_pipeline(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory) / "proj"
|
||||
root.mkdir()
|
||||
coords = np.linspace(0.0, 30.0, 31)
|
||||
gx, gy = np.meshgrid(coords, coords)
|
||||
z = 5.0 + 0.04 * gx + 0.02 * gy
|
||||
src = Path(directory) / "cloud.las"
|
||||
las = laspy.LasData(laspy.LasHeader(point_format=3, version="1.2"))
|
||||
las.x, las.y, las.z = gx.ravel(), gy.ravel(), z.ravel()
|
||||
las.write(src)
|
||||
|
||||
result = run_surface_analysis(
|
||||
root, src, source_filters=["grid_min_z"], methods=["dtm", "tin"]
|
||||
)
|
||||
|
||||
self.assertEqual(result["processed"]["point_count"], 961)
|
||||
self.assertEqual(result["manifest"]["status"], "completed")
|
||||
self.assertGreater(result["ground_summary"]["grid_min_z"]["ground_ratio"], 0.9)
|
||||
model_types = {m["model_type"] for m in result["models"]}
|
||||
self.assertEqual(model_types, {"dtm", "tin"})
|
||||
for model in result["models"]:
|
||||
self.assertTrue(model["model_file_path"].startswith("B04_wf1_Surface/"))
|
||||
self.assertTrue(model["layers"])
|
||||
# structured.npz가 processed 폴더에 생성됨
|
||||
self.assertTrue((root / "B04_wf1_Surface" / "processed" / "structured.npz").is_file())
|
||||
@@ -0,0 +1,48 @@
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Filter_CSF import filter_csf
|
||||
|
||||
|
||||
class FilterCsfTest(unittest.TestCase):
|
||||
def _ground_with_trees(self) -> dict[str, np.ndarray]:
|
||||
"""평탄한 지면 격자 위에 높이 솟은 수목 포인트를 얹은 합성 데이터."""
|
||||
coords = np.linspace(0.0, 10.0, 11)
|
||||
gx, gy = np.meshgrid(coords, coords)
|
||||
ground = np.column_stack([gx.ravel(), gy.ravel(), np.full(gx.size, 5.0)])
|
||||
trees = np.array(
|
||||
[
|
||||
[2.0, 2.0, 15.0],
|
||||
[5.0, 5.0, 18.0],
|
||||
[8.0, 8.0, 16.0],
|
||||
]
|
||||
)
|
||||
xyz = np.vstack([ground, trees])
|
||||
return {"xyz": xyz}, ground.shape[0]
|
||||
|
||||
def test_classifies_ground_and_rejects_trees(self) -> None:
|
||||
structured, ground_count = self._ground_with_trees()
|
||||
|
||||
mask = filter_csf(structured, cloth_resolution=1.5, class_threshold=0.5)
|
||||
|
||||
self.assertEqual(mask.shape[0], structured["xyz"].shape[0])
|
||||
self.assertEqual(mask.dtype, bool)
|
||||
# 지면 포인트는 대부분 지면으로 분류되어야 한다.
|
||||
self.assertGreater(mask[:ground_count].mean(), 0.8)
|
||||
# 솟은 수목 포인트는 지면에서 제외되어야 한다.
|
||||
self.assertFalse(mask[ground_count:].any())
|
||||
|
||||
def test_empty_input(self) -> None:
|
||||
mask = filter_csf({"xyz": np.zeros((0, 3))})
|
||||
self.assertEqual(mask.shape[0], 0)
|
||||
self.assertEqual(mask.dtype, bool)
|
||||
|
||||
def test_invalid_parameters(self) -> None:
|
||||
structured, _ = self._ground_with_trees()
|
||||
with self.assertRaises(ValueError):
|
||||
filter_csf(structured, cloth_resolution=0.0)
|
||||
with self.assertRaises(ValueError):
|
||||
filter_csf(structured, rigidness=5)
|
||||
with self.assertRaises(ValueError):
|
||||
filter_csf(structured, iterations=0)
|
||||
@@ -0,0 +1,25 @@
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Filter_Grid import filter_grid_min_z
|
||||
|
||||
|
||||
class FilterGridMinZTest(unittest.TestCase):
|
||||
def test_filter_grid_min_z(self) -> None:
|
||||
structured = {
|
||||
"xyz": np.array(
|
||||
[
|
||||
[0.0, 0.0, 10.0],
|
||||
[0.5, 0.5, 11.0],
|
||||
[0.8, 0.8, 12.0],
|
||||
]
|
||||
),
|
||||
"bounds": np.array([[0.0, 0.8], [0.0, 0.8], [10.0, 12.0]]),
|
||||
}
|
||||
|
||||
mask = filter_grid_min_z(structured, cell_size=2.0, height_threshold=1.5)
|
||||
|
||||
np.testing.assert_array_equal(mask, [True, True, False])
|
||||
with self.assertRaises(ValueError):
|
||||
filter_grid_min_z(structured, cell_size=0)
|
||||
@@ -0,0 +1,42 @@
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Filter_PMF import filter_pmf
|
||||
|
||||
|
||||
class FilterPmfTest(unittest.TestCase):
|
||||
def _ground_with_trees(self) -> tuple[dict[str, np.ndarray], int]:
|
||||
coords = np.linspace(0.0, 40.0, 21)
|
||||
gx, gy = np.meshgrid(coords, coords)
|
||||
ground = np.column_stack([gx.ravel(), gy.ravel(), np.full(gx.size, 5.0)])
|
||||
trees = np.array(
|
||||
[
|
||||
[10.0, 10.0, 20.0],
|
||||
[20.0, 20.0, 25.0],
|
||||
[30.0, 30.0, 22.0],
|
||||
]
|
||||
)
|
||||
xyz = np.vstack([ground, trees])
|
||||
bounds = np.array([[0.0, 40.0], [0.0, 40.0], [5.0, 25.0]])
|
||||
return {"xyz": xyz, "bounds": bounds}, ground.shape[0]
|
||||
|
||||
def test_classifies_ground_and_rejects_trees(self) -> None:
|
||||
structured, ground_count = self._ground_with_trees()
|
||||
mask = filter_pmf(structured)
|
||||
self.assertEqual(mask.shape[0], structured["xyz"].shape[0])
|
||||
self.assertEqual(mask.dtype, bool)
|
||||
self.assertGreater(mask[:ground_count].mean(), 0.9)
|
||||
self.assertFalse(mask[ground_count:].any())
|
||||
|
||||
def test_empty_input(self) -> None:
|
||||
bounds = np.array([[0.0, 1.0], [0.0, 1.0], [0.0, 1.0]])
|
||||
mask = filter_pmf({"xyz": np.zeros((0, 3)), "bounds": bounds})
|
||||
self.assertEqual(mask.shape[0], 0)
|
||||
|
||||
def test_invalid_parameters(self) -> None:
|
||||
structured, _ = self._ground_with_trees()
|
||||
with self.assertRaises(ValueError):
|
||||
filter_pmf(structured, cell_size=0)
|
||||
with self.assertRaises(ValueError):
|
||||
filter_pmf(structured, initial_window_size=0)
|
||||
@@ -0,0 +1,59 @@
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Filter_RANSAC import (
|
||||
filter_ransac,
|
||||
fit_plane_ransac,
|
||||
)
|
||||
|
||||
|
||||
class FitPlaneRansacTest(unittest.TestCase):
|
||||
def test_fits_horizontal_plane(self) -> None:
|
||||
coords = np.linspace(0.0, 5.0, 6)
|
||||
gx, gy = np.meshgrid(coords, coords)
|
||||
plane = np.column_stack([gx.ravel(), gy.ravel(), np.full(gx.size, 2.0)])
|
||||
outliers = np.array([[1.0, 1.0, 10.0], [3.0, 3.0, 12.0]])
|
||||
points = np.vstack([plane, outliers])
|
||||
inliers = fit_plane_ransac(points, distance_threshold=0.3)
|
||||
self.assertTrue(inliers[: plane.shape[0]].all())
|
||||
self.assertFalse(inliers[plane.shape[0] :].any())
|
||||
|
||||
def test_too_few_points(self) -> None:
|
||||
points = np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]])
|
||||
inliers = fit_plane_ransac(points)
|
||||
self.assertTrue(inliers.all())
|
||||
|
||||
|
||||
class FilterRansacTest(unittest.TestCase):
|
||||
def _ground_with_trees(self) -> tuple[dict[str, np.ndarray], int]:
|
||||
coords = np.linspace(0.0, 20.0, 11)
|
||||
gx, gy = np.meshgrid(coords, coords)
|
||||
ground = np.column_stack([gx.ravel(), gy.ravel(), np.full(gx.size, 3.0)])
|
||||
trees = np.array([[5.0, 5.0, 15.0], [15.0, 15.0, 18.0]])
|
||||
xyz = np.vstack([ground, trees])
|
||||
bounds = np.array([[0.0, 20.0], [0.0, 20.0], [3.0, 18.0]])
|
||||
return {"xyz": xyz, "bounds": bounds}, ground.shape[0]
|
||||
|
||||
def test_classifies_ground(self) -> None:
|
||||
structured, ground_count = self._ground_with_trees()
|
||||
progress: list[int] = []
|
||||
mask = filter_ransac(structured, progress_callback=progress.append)
|
||||
self.assertEqual(mask.shape[0], structured["xyz"].shape[0])
|
||||
self.assertGreater(mask[:ground_count].mean(), 0.9)
|
||||
# progress는 호출되며 단조 증가하고 마지막에 100으로 완료된다.
|
||||
self.assertTrue(progress)
|
||||
self.assertEqual(progress, sorted(progress))
|
||||
self.assertEqual(progress[-1], 100)
|
||||
|
||||
def test_empty_input(self) -> None:
|
||||
bounds = np.array([[0.0, 1.0], [0.0, 1.0], [0.0, 1.0]])
|
||||
mask = filter_ransac({"xyz": np.zeros((0, 3)), "bounds": bounds})
|
||||
self.assertEqual(mask.shape[0], 0)
|
||||
|
||||
def test_invalid_parameters(self) -> None:
|
||||
structured, _ = self._ground_with_trees()
|
||||
with self.assertRaises(ValueError):
|
||||
filter_ransac(structured, local_grid_size=0)
|
||||
with self.assertRaises(ValueError):
|
||||
filter_ransac(structured, ransac_n=2)
|
||||
@@ -0,0 +1,61 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Pipeline import build_all_terrain_models
|
||||
from config.config_system import build_surface_model_config
|
||||
|
||||
|
||||
class BuildAllTerrainModelsTest(unittest.TestCase):
|
||||
def _synthetic_slope(self) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray]]:
|
||||
coords = np.linspace(0.0, 40.0, 41)
|
||||
gx, gy = np.meshgrid(coords, coords)
|
||||
z = 5.0 + 0.05 * gx + 0.03 * gy
|
||||
xyz = np.column_stack([gx.ravel(), gy.ravel(), z.ravel()]).astype(np.float64)
|
||||
bounds = np.array([[0.0, 40.0], [0.0, 40.0], [float(z.min()), float(z.max())]])
|
||||
mask = np.ones(len(xyz), dtype=bool)
|
||||
return {"xyz": xyz, "bounds": bounds}, {"grid_min_z": mask}
|
||||
|
||||
def test_builds_all_representations(self) -> None:
|
||||
structured, masks = self._synthetic_slope()
|
||||
config = build_surface_model_config()
|
||||
config["source_filters"] = ["grid_min_z"]
|
||||
progress: list[tuple[int, str]] = []
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output_dir = Path(directory) / "proj" / "models"
|
||||
manifest = build_all_terrain_models(
|
||||
structured, masks, output_dir, config, progress=lambda p, m: progress.append((p, m))
|
||||
)
|
||||
|
||||
self.assertEqual(manifest["status"], "completed")
|
||||
self.assertEqual(manifest["failure_count"], 0)
|
||||
methods = manifest["source_filters"]["grid_min_z"]["methods"]
|
||||
self.assertEqual(set(methods), {"tin", "dtm", "nurbs", "implicit", "meshfree"})
|
||||
for meta in methods.values():
|
||||
self.assertEqual(meta["status"], "completed")
|
||||
# DTM/TIN은 스무딩까지 완료
|
||||
self.assertEqual(methods["dtm"]["smooth"]["status"], "completed")
|
||||
self.assertEqual(methods["tin"]["smooth"]["status"], "completed")
|
||||
# 등고선 캐시와 manifest 생성 확인
|
||||
files = list(output_dir.iterdir())
|
||||
self.assertTrue(any("contour_" in f.name for f in files))
|
||||
self.assertTrue((output_dir / "manifest.json").is_file())
|
||||
self.assertEqual(progress[-1][0], 100)
|
||||
|
||||
def test_cache_reuse_on_second_call(self) -> None:
|
||||
structured, masks = self._synthetic_slope()
|
||||
config = build_surface_model_config()
|
||||
config["source_filters"] = ["grid_min_z"]
|
||||
config["precompute"] = ["dtm"]
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output_dir = Path(directory) / "proj" / "models"
|
||||
build_all_terrain_models(structured, masks, output_dir, config)
|
||||
second = build_all_terrain_models(structured, masks, output_dir, config)
|
||||
self.assertEqual(second["status"], "completed")
|
||||
self.assertEqual(
|
||||
second["source_filters"]["grid_min_z"]["methods"]["dtm"]["status"], "completed"
|
||||
)
|
||||
@@ -0,0 +1,143 @@
|
||||
import json
|
||||
import unittest
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Repository import (
|
||||
create_processed_point_cloud,
|
||||
create_surface_model,
|
||||
create_terrain_layer,
|
||||
list_surface_models,
|
||||
)
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
def __init__(self, *, lastrowid: int = 0, rows: list[tuple[Any, ...]] | None = None) -> None:
|
||||
self.query = ""
|
||||
self.arguments: tuple[Any, ...] = ()
|
||||
self.lastrowid = lastrowid
|
||||
self._rows = rows or []
|
||||
|
||||
async def __aenter__(self) -> "FakeCursor":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_exc: Any) -> None:
|
||||
return None
|
||||
|
||||
async def execute(self, query: str, arguments: tuple[Any, ...] | None = None) -> None:
|
||||
self.query = query
|
||||
self.arguments = arguments or ()
|
||||
|
||||
async def fetchall(self) -> list[tuple[Any, ...]]:
|
||||
return self._rows
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self, cursor: FakeCursor) -> None:
|
||||
self._cursor = cursor
|
||||
|
||||
def cursor(self) -> FakeCursor:
|
||||
return self._cursor
|
||||
|
||||
|
||||
PROJECT_ID = UUID("550e8400-e29b-41d4-a716-446655440000")
|
||||
|
||||
|
||||
class ProcessedPointCloudTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_create(self) -> None:
|
||||
cursor = FakeCursor(lastrowid=11)
|
||||
new_id = await create_processed_point_cloud( # type: ignore[arg-type]
|
||||
FakeConnection(cursor),
|
||||
input_file_id=3,
|
||||
project_id=PROJECT_ID,
|
||||
process_type="filtered",
|
||||
processed_file_path="B04_wf1_Surface/processed/cloud.las",
|
||||
converted_format="ply",
|
||||
converted_file_path="B04_wf1_Surface/processed/cloud.ply",
|
||||
point_count=1000,
|
||||
bounds={"x_min": 0.0, "x_max": 10.0, "y_min": 0.0, "y_max": 10.0},
|
||||
statistics={"min_z": 1.0, "max_z": 5.0, "mean_z": 3.0, "density_per_sqm": 10.0},
|
||||
classification_summary={"ground": 800},
|
||||
processing_params={"filter": "csf"},
|
||||
)
|
||||
self.assertEqual(new_id, 11)
|
||||
self.assertIn("INSERT INTO processed_point_cloud", cursor.query)
|
||||
self.assertEqual(cursor.arguments[1], str(PROJECT_ID))
|
||||
self.assertEqual(cursor.arguments[6], 1000)
|
||||
self.assertEqual(json.loads(cursor.arguments[15]), {"ground": 800})
|
||||
|
||||
async def test_rejects_path_outside_stage(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
await create_processed_point_cloud( # type: ignore[arg-type]
|
||||
FakeConnection(FakeCursor(lastrowid=1)),
|
||||
input_file_id=3,
|
||||
project_id=PROJECT_ID,
|
||||
process_type="filtered",
|
||||
processed_file_path="B05_wf2_Route/x.las",
|
||||
converted_format=None,
|
||||
converted_file_path=None,
|
||||
point_count=None,
|
||||
bounds=None,
|
||||
statistics=None,
|
||||
classification_summary=None,
|
||||
processing_params=None,
|
||||
)
|
||||
|
||||
|
||||
class SurfaceModelTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_create(self) -> None:
|
||||
cursor = FakeCursor(lastrowid=22)
|
||||
new_id = await create_surface_model( # type: ignore[arg-type]
|
||||
FakeConnection(cursor),
|
||||
project_id=PROJECT_ID,
|
||||
model_type="dtm_grid",
|
||||
source_file_id=3,
|
||||
processed_cloud_id=11,
|
||||
crs_epsg=5178,
|
||||
resolution_m=1.0,
|
||||
model_file_path="B04_wf1_Surface/models/dtm.npz",
|
||||
generation_params={"algorithm": "dtm"},
|
||||
)
|
||||
self.assertEqual(new_id, 22)
|
||||
self.assertIn("INSERT INTO surface_models", cursor.query)
|
||||
self.assertEqual(cursor.arguments[7], "B04_wf1_Surface/models/dtm.npz")
|
||||
|
||||
|
||||
class TerrainLayerTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_create(self) -> None:
|
||||
cursor = FakeCursor(lastrowid=33)
|
||||
new_id = await create_terrain_layer( # type: ignore[arg-type]
|
||||
FakeConnection(cursor),
|
||||
surface_model_id=22,
|
||||
layer_name="지표",
|
||||
geometry_type="GRID",
|
||||
layer_file_path="B04_wf1_Surface/models/layer_ground.geojson",
|
||||
file_format="geojson",
|
||||
file_size_mb=1.5,
|
||||
statistics={"point_count": 500},
|
||||
)
|
||||
self.assertEqual(new_id, 33)
|
||||
self.assertIn("INSERT INTO terrain_layers", cursor.query)
|
||||
|
||||
|
||||
class ListSurfaceModelsTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_list(self) -> None:
|
||||
import datetime
|
||||
|
||||
rows = [
|
||||
(
|
||||
22,
|
||||
"dtm_grid",
|
||||
"COMPLETE",
|
||||
1.0,
|
||||
"B04_wf1_Surface/models/dtm.npz",
|
||||
datetime.datetime(2026, 7, 5, 12, 0, 0),
|
||||
),
|
||||
]
|
||||
result = await list_surface_models( # type: ignore[arg-type]
|
||||
FakeConnection(FakeCursor(rows=rows)), PROJECT_ID
|
||||
)
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0]["id"], 22)
|
||||
self.assertEqual(result[0]["model_type"], "dtm_grid")
|
||||
self.assertTrue(result[0]["created_at"].startswith("2026-07-05"))
|
||||
@@ -0,0 +1,40 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import laspy
|
||||
import numpy as np
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Structurize import structurize_las
|
||||
|
||||
|
||||
class StructurizeLasTest(unittest.TestCase):
|
||||
def test_structurize_las(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
source = Path(temporary_directory) / "source.las"
|
||||
output_dir = Path(temporary_directory) / "processed"
|
||||
las = laspy.LasData(laspy.LasHeader(point_format=3, version="1.2"))
|
||||
las.x = np.array([100.0, 102.0])
|
||||
las.y = np.array([200.0, 204.0])
|
||||
las.z = np.array([10.0, 14.0])
|
||||
las.intensity = np.array([7, 9], dtype=np.uint16)
|
||||
las.red = np.array([65535, 256], dtype=np.uint16)
|
||||
las.green = np.array([32768, 512], dtype=np.uint16)
|
||||
las.blue = np.array([0, 768], dtype=np.uint16)
|
||||
las.classification = np.array([2, 5], dtype=np.uint8)
|
||||
las.write(source)
|
||||
progress: list[int] = []
|
||||
|
||||
target = structurize_las(source, output_dir, progress.append)
|
||||
|
||||
self.assertEqual(target, output_dir / "structured.npz")
|
||||
self.assertEqual(progress[-1], 100)
|
||||
with np.load(target) as structured:
|
||||
np.testing.assert_allclose(
|
||||
structured["xyz"],
|
||||
np.array([[100.0, 200.0, 10.0], [102.0, 204.0, 14.0]]),
|
||||
)
|
||||
np.testing.assert_array_equal(structured["intensity"], [7, 9])
|
||||
np.testing.assert_array_equal(structured["rgb"], [[255, 128, 0], [1, 2, 3]])
|
||||
np.testing.assert_array_equal(structured["classification"], [2, 5])
|
||||
np.testing.assert_array_equal(structured["total_points"], [2])
|
||||
@@ -0,0 +1,64 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Pipeline import build_all_terrain_models
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine import run_route_design
|
||||
from B05_wf2_Route.B05_wf2_Route_Router import router
|
||||
from config.config_system import build_surface_model_config
|
||||
|
||||
|
||||
class RouteEngineTest(unittest.TestCase):
|
||||
def _make_dtm(self, root: Path) -> None:
|
||||
models = root / "B04_wf1_Surface" / "models"
|
||||
coords = np.linspace(0.0, 60.0, 61)
|
||||
gx, gy = np.meshgrid(coords, coords)
|
||||
z = 5.0 + 0.03 * gx + 0.02 * gy
|
||||
xyz = np.column_stack([gx.ravel(), gy.ravel(), z.ravel()]).astype(np.float64)
|
||||
bounds = np.array([[0.0, 60.0], [0.0, 60.0], [float(z.min()), float(z.max())]])
|
||||
cfg = build_surface_model_config()
|
||||
cfg["source_filters"] = ["grid_min_z"]
|
||||
cfg["precompute"] = ["dtm"]
|
||||
build_all_terrain_models(
|
||||
{"xyz": xyz, "bounds": bounds},
|
||||
{"grid_min_z": np.ones(len(xyz), dtype=bool)},
|
||||
models,
|
||||
cfg,
|
||||
)
|
||||
|
||||
def test_run_route_design(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory) / "proj"
|
||||
self._make_dtm(root)
|
||||
points = {
|
||||
"bp": {"x": 5.0, "y": 5.0},
|
||||
"ep": {"x": 55.0, "y": 55.0},
|
||||
"cp": [],
|
||||
"ap": [],
|
||||
"fp": [],
|
||||
}
|
||||
design = run_route_design(root, "grid_min_z", "dtm", False, points, {})
|
||||
|
||||
# GeoJSON 저장 확인
|
||||
self.assertEqual(design["route_data_path"], "B05_wf2_Route/route/route_main.geojson")
|
||||
geojson_file = root / design["route_data_path"]
|
||||
self.assertTrue(geojson_file.is_file())
|
||||
geo = json.loads(geojson_file.read_text(encoding="utf-8"))
|
||||
self.assertEqual(geo["geometry"]["type"], "LineString")
|
||||
self.assertGreater(len(geo["geometry"]["coordinates"]), 2)
|
||||
|
||||
# 렌더링 샘플 & 통계
|
||||
self.assertTrue(design["render_points"])
|
||||
self.assertEqual(design["render_points"][0]["sequence_num"], 0)
|
||||
self.assertIsNotNone(design["statistics"]["max_slope"])
|
||||
self.assertTrue(design["solver_result"]["required_points_ok"])
|
||||
|
||||
|
||||
class RouterRegistrationTest(unittest.TestCase):
|
||||
def test_routes_registered(self) -> None:
|
||||
paths = {r.path for r in router.routes}
|
||||
self.assertIn("/api/projects/{project_id}/route/solve", paths)
|
||||
self.assertIn("/api/projects/{project_id}/route/confirm", paths)
|
||||
@@ -0,0 +1,148 @@
|
||||
import json
|
||||
import unittest
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_Repository import (
|
||||
confirm_route,
|
||||
create_route,
|
||||
create_route_statistics,
|
||||
get_latest_route,
|
||||
insert_route_points,
|
||||
)
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
def __init__(self, *, lastrowid: int = 0, row: tuple[Any, ...] | None = None) -> None:
|
||||
self.query = ""
|
||||
self.arguments: Any = ()
|
||||
self.many_rows: list[tuple[Any, ...]] = []
|
||||
self.lastrowid = lastrowid
|
||||
self._row = row
|
||||
|
||||
async def __aenter__(self) -> "FakeCursor":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_exc: Any) -> None:
|
||||
return None
|
||||
|
||||
async def execute(self, query: str, arguments: Any = None) -> None:
|
||||
self.query = query
|
||||
self.arguments = arguments or ()
|
||||
|
||||
async def executemany(self, query: str, rows: list[tuple[Any, ...]]) -> None:
|
||||
self.query = query
|
||||
self.many_rows = rows
|
||||
|
||||
async def fetchone(self) -> tuple[Any, ...] | None:
|
||||
return self._row
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self, cursor: FakeCursor) -> None:
|
||||
self._cursor = cursor
|
||||
|
||||
def cursor(self) -> FakeCursor:
|
||||
return self._cursor
|
||||
|
||||
|
||||
PROJECT_ID = UUID("550e8400-e29b-41d4-a716-446655440000")
|
||||
|
||||
|
||||
class CreateRouteTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_create(self) -> None:
|
||||
cursor = FakeCursor(lastrowid=7)
|
||||
route_id = await create_route( # type: ignore[arg-type]
|
||||
FakeConnection(cursor),
|
||||
project_id=PROJECT_ID,
|
||||
surface_model_id=22,
|
||||
total_length_m=100.0,
|
||||
start_chainage_m=0.0,
|
||||
end_chainage_m=100.0,
|
||||
grade_percent=[2.5, 1.8],
|
||||
constraints={"max_grade": 0.14},
|
||||
algorithm_params={"weights": {"dist": 1.0}},
|
||||
route_data_path="B05_wf2_Route/route/route_main.geojson",
|
||||
)
|
||||
self.assertEqual(route_id, 7)
|
||||
self.assertIn("INSERT INTO routes", cursor.query)
|
||||
self.assertEqual(cursor.arguments[0], str(PROJECT_ID))
|
||||
self.assertEqual(json.loads(cursor.arguments[6]), [2.5, 1.8])
|
||||
|
||||
async def test_rejects_path_outside_stage(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
await create_route( # type: ignore[arg-type]
|
||||
FakeConnection(FakeCursor(lastrowid=1)),
|
||||
project_id=PROJECT_ID,
|
||||
surface_model_id=None,
|
||||
total_length_m=None,
|
||||
start_chainage_m=None,
|
||||
end_chainage_m=None,
|
||||
grade_percent=None,
|
||||
constraints=None,
|
||||
algorithm_params=None,
|
||||
route_data_path="B04_wf1_Surface/x.geojson",
|
||||
)
|
||||
|
||||
|
||||
class RoutePointsTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_insert_many(self) -> None:
|
||||
cursor = FakeCursor()
|
||||
count = await insert_route_points( # type: ignore[arg-type]
|
||||
FakeConnection(cursor),
|
||||
7,
|
||||
[
|
||||
{"chainage_m": 0.0, "elevation_m": 5.0, "slope_percent": 0.0, "sequence_num": 0},
|
||||
{"chainage_m": 10.0, "elevation_m": 5.3, "slope_percent": 3.0, "sequence_num": 1},
|
||||
],
|
||||
)
|
||||
self.assertEqual(count, 2)
|
||||
self.assertEqual(len(cursor.many_rows), 2)
|
||||
self.assertEqual(cursor.many_rows[0][0], 7)
|
||||
|
||||
async def test_insert_empty(self) -> None:
|
||||
count = await insert_route_points(FakeConnection(FakeCursor()), 7, []) # type: ignore[arg-type]
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
|
||||
class RouteStatisticsTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_create(self) -> None:
|
||||
cursor = FakeCursor(lastrowid=3)
|
||||
stat_id = await create_route_statistics( # type: ignore[arg-type]
|
||||
FakeConnection(cursor),
|
||||
route_id=7,
|
||||
min_slope=0.0,
|
||||
max_slope=7.0,
|
||||
mean_slope=3.5,
|
||||
cost_score=12.3,
|
||||
)
|
||||
self.assertEqual(stat_id, 3)
|
||||
self.assertIn("INSERT INTO route_statistics", cursor.query)
|
||||
|
||||
|
||||
class LatestRouteTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_found(self) -> None:
|
||||
import datetime
|
||||
|
||||
row = (
|
||||
7,
|
||||
"CONFIRMED",
|
||||
100.0,
|
||||
"B05_wf2_Route/route/route_main.geojson",
|
||||
datetime.datetime(2026, 7, 5, 12, 0, 0),
|
||||
)
|
||||
result = await get_latest_route(FakeConnection(FakeCursor(row=row)), PROJECT_ID) # type: ignore[arg-type]
|
||||
self.assertEqual(result["id"], 7)
|
||||
self.assertEqual(result["status"], "CONFIRMED")
|
||||
|
||||
async def test_none(self) -> None:
|
||||
result = await get_latest_route(FakeConnection(FakeCursor(row=None)), PROJECT_ID) # type: ignore[arg-type]
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
class ConfirmRouteTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_confirm(self) -> None:
|
||||
cursor = FakeCursor()
|
||||
await confirm_route(FakeConnection(cursor), 7) # type: ignore[arg-type]
|
||||
self.assertIn("UPDATE routes SET status = 'CONFIRMED'", cursor.query)
|
||||
self.assertEqual(cursor.arguments[0], 7)
|
||||
@@ -0,0 +1,44 @@
|
||||
import math
|
||||
import unittest
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_RidgeValley import (
|
||||
_fillet_alignment,
|
||||
_turn_angle,
|
||||
resolve_grade_bounds,
|
||||
)
|
||||
|
||||
|
||||
class ResolveGradeBoundsTest(unittest.TestCase):
|
||||
def test_defaults(self) -> None:
|
||||
gb = resolve_grade_bounds({})
|
||||
self.assertAlmostEqual(gb["min_uphill_grade"], 0.08)
|
||||
self.assertAlmostEqual(gb["max_uphill_grade"], 0.14)
|
||||
# 엣지용은 방향 중 더 엄격한 값
|
||||
self.assertAlmostEqual(gb["max_grade_for_edges"], 0.14)
|
||||
self.assertAlmostEqual(gb["min_grade_for_edges"], 0.08)
|
||||
|
||||
def test_override(self) -> None:
|
||||
gb = resolve_grade_bounds({"max_uphill_grade": 0.10, "max_downhill_grade": 0.12})
|
||||
self.assertAlmostEqual(gb["max_grade_for_edges"], 0.10)
|
||||
|
||||
|
||||
class TurnAngleTest(unittest.TestCase):
|
||||
def test_straight_is_zero(self) -> None:
|
||||
self.assertAlmostEqual(_turn_angle([0, 0], [1, 0], [2, 0]), 0.0, places=6)
|
||||
|
||||
def test_right_angle(self) -> None:
|
||||
self.assertAlmostEqual(_turn_angle([0, 0], [1, 0], [1, 1]), math.pi / 2, places=6)
|
||||
|
||||
|
||||
class FilletAlignmentTest(unittest.TestCase):
|
||||
def test_straight_nodes_preserved(self) -> None:
|
||||
nodes = [[0, 0, 0], [10, 0, 1], [20, 0, 2]]
|
||||
poly, radii = _fillet_alignment(nodes, 12.0, 2.0)
|
||||
self.assertGreaterEqual(len(poly), 2)
|
||||
# 직선이므로 시작·끝 좌표 보존
|
||||
self.assertAlmostEqual(poly[0][0], 0.0)
|
||||
self.assertAlmostEqual(poly[-1][0], 20.0)
|
||||
|
||||
def test_single_node(self) -> None:
|
||||
poly, radii = _fillet_alignment([[0, 0, 0]], 12.0, 2.0)
|
||||
self.assertEqual(len(poly), 1)
|
||||
@@ -0,0 +1,51 @@
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Skeleton import (
|
||||
d8_flow_accumulation_numpy,
|
||||
extract_skeleton_from_grid,
|
||||
)
|
||||
|
||||
|
||||
class D8FlowAccumulationTest(unittest.TestCase):
|
||||
def test_valley_concentrates_flow(self) -> None:
|
||||
# V자 계곡: 중앙 열(x=20)이 가장 낮다 → 흐름이 중앙에 모인다.
|
||||
coords = np.linspace(0, 40, 41)
|
||||
gx, gy = np.meshgrid(coords, coords)
|
||||
z = 10.0 + np.abs(gx - 20.0) * 0.3
|
||||
valid = np.ones_like(z, dtype=bool)
|
||||
|
||||
acc = d8_flow_accumulation_numpy(z, valid)
|
||||
# 누적 최대 셀은 중앙 열(index 20) 근처여야 한다.
|
||||
_, max_col = np.unravel_index(acc.argmax(), acc.shape)
|
||||
self.assertTrue(18 <= max_col <= 22)
|
||||
self.assertGreater(acc.max(), 10.0)
|
||||
|
||||
def test_flat_grid_uniform(self) -> None:
|
||||
z = np.full((10, 10), 5.0)
|
||||
valid = np.ones_like(z, dtype=bool)
|
||||
acc = d8_flow_accumulation_numpy(z, valid)
|
||||
# 평지는 하강 이웃이 없어 각 셀 누적이 1.
|
||||
self.assertTrue(np.allclose(acc, 1.0))
|
||||
|
||||
|
||||
class ExtractSkeletonTest(unittest.TestCase):
|
||||
def test_valley_detected_with_low_threshold(self) -> None:
|
||||
coords = np.linspace(0, 40, 41)
|
||||
gx, gy = np.meshgrid(coords, coords)
|
||||
z = 10.0 + np.abs(gx - 20.0) * 0.3
|
||||
valid = np.ones_like(z, dtype=bool)
|
||||
thresholds = {
|
||||
"valley_acc": 5.0,
|
||||
"main_valley_acc": 30.0,
|
||||
"ridge_acc": 5.0,
|
||||
"main_ridge_acc": 30.0,
|
||||
}
|
||||
result = extract_skeleton_from_grid(
|
||||
coords, coords, z, valid, 1.0, thresholds=thresholds, use_whitebox=False
|
||||
)
|
||||
self.assertEqual(set(result), {"main_ridge", "minor_ridge", "main_valley", "minor_valley"})
|
||||
# 계곡 클래스에 최소 하나의 polyline이 검출되어야 한다.
|
||||
valley_total = len(result["main_valley"]) + len(result["minor_valley"])
|
||||
self.assertGreater(valley_total, 0)
|
||||
@@ -0,0 +1,103 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Pipeline import build_all_terrain_models
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Geometry import (
|
||||
circumradius_2d,
|
||||
point_to_polyline_dist_2d,
|
||||
single_segment_dijkstra,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Solver import solve_optimal_route
|
||||
from config.config_system import build_surface_model_config
|
||||
|
||||
|
||||
class GeometryTest(unittest.TestCase):
|
||||
def test_circumradius_straight_line_is_inf(self) -> None:
|
||||
r = circumradius_2d([0, 0], [1, 0], [2, 0])
|
||||
self.assertEqual(r, float("inf"))
|
||||
|
||||
def test_circumradius_right_angle(self) -> None:
|
||||
r = circumradius_2d([0, 0], [1, 0], [1, 1])
|
||||
self.assertTrue(np.isfinite(r) and r > 0)
|
||||
|
||||
def test_point_to_polyline_distance(self) -> None:
|
||||
poly = [[0, 0], [10, 0]]
|
||||
self.assertAlmostEqual(point_to_polyline_dist_2d(5, 3, poly), 3.0, places=6)
|
||||
|
||||
def test_dijkstra_on_flat_grid(self) -> None:
|
||||
coords = np.linspace(0.0, 10.0, 11)
|
||||
z = np.full((11, 11), 5.0)
|
||||
valid = np.ones((11, 11), dtype=bool)
|
||||
dz_dx = np.zeros((11, 11))
|
||||
dz_dy = np.zeros((11, 11))
|
||||
weights = {"dist": 1.0, "grade": 2.0, "side": 1.5, "curve": 0.5, "avoid": 10.0}
|
||||
path = single_segment_dijkstra(
|
||||
0,
|
||||
0,
|
||||
10,
|
||||
10,
|
||||
coords,
|
||||
coords,
|
||||
z,
|
||||
valid,
|
||||
dz_dx,
|
||||
dz_dy,
|
||||
[],
|
||||
weights,
|
||||
0.14,
|
||||
1.0,
|
||||
12.0,
|
||||
)
|
||||
self.assertTrue(path)
|
||||
self.assertEqual(path[0], (0, 0))
|
||||
self.assertEqual(path[-1], (10, 10))
|
||||
|
||||
|
||||
class SolveOptimalRouteTest(unittest.TestCase):
|
||||
def _make_dtm(self, root: Path) -> None:
|
||||
models = root / "B04_wf1_Surface" / "models"
|
||||
coords = np.linspace(0.0, 60.0, 61)
|
||||
gx, gy = np.meshgrid(coords, coords)
|
||||
z = 5.0 + 0.03 * gx + 0.02 * gy
|
||||
xyz = np.column_stack([gx.ravel(), gy.ravel(), z.ravel()]).astype(np.float64)
|
||||
bounds = np.array([[0.0, 60.0], [0.0, 60.0], [float(z.min()), float(z.max())]])
|
||||
cfg = build_surface_model_config()
|
||||
cfg["source_filters"] = ["grid_min_z"]
|
||||
cfg["precompute"] = ["dtm"]
|
||||
build_all_terrain_models(
|
||||
{"xyz": xyz, "bounds": bounds},
|
||||
{"grid_min_z": np.ones(len(xyz), dtype=bool)},
|
||||
models,
|
||||
cfg,
|
||||
)
|
||||
|
||||
def test_diagonal_route(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory) / "proj"
|
||||
self._make_dtm(root)
|
||||
points = {
|
||||
"bp": {"x": 5.0, "y": 5.0},
|
||||
"ep": {"x": 55.0, "y": 55.0},
|
||||
"cp": [],
|
||||
"ap": [],
|
||||
"fp": [],
|
||||
}
|
||||
result = solve_optimal_route(root, "grid_min_z", False, points, {}, method="dtm")
|
||||
|
||||
self.assertGreater(len(result["polyline"]), 2)
|
||||
self.assertTrue(result["required_points_ok"])
|
||||
self.assertEqual(len(result["segments"]), 1)
|
||||
# 대각선 50m×50m → 길이 ≈ 70.7m
|
||||
self.assertAlmostEqual(result["metrics"]["length_m"], 70.71, delta=5.0)
|
||||
|
||||
def test_missing_bp_returns_empty(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory) / "proj"
|
||||
self._make_dtm(root)
|
||||
points = {"bp": None, "ep": {"x": 55.0, "y": 55.0}, "cp": [], "ap": [], "fp": []}
|
||||
result = solve_optimal_route(root, "grid_min_z", False, points, {}, method="dtm")
|
||||
self.assertEqual(result["polyline"], [])
|
||||
self.assertFalse(result["required_points_ok"])
|
||||
@@ -0,0 +1,71 @@
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Sampler import (
|
||||
CallableSurfaceSampler,
|
||||
DtmGridSampler,
|
||||
)
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Section import (
|
||||
SectionGenerationOptions,
|
||||
format_station,
|
||||
generate_sections,
|
||||
)
|
||||
|
||||
|
||||
class FormatStationTest(unittest.TestCase):
|
||||
def test_format(self) -> None:
|
||||
self.assertEqual(format_station(0.0), "STA.0+000.000")
|
||||
self.assertEqual(format_station(1234.5), "STA.1+234.500")
|
||||
self.assertEqual(format_station(20.0), "STA.0+020.000")
|
||||
|
||||
|
||||
class DtmGridSamplerTest(unittest.TestCase):
|
||||
def test_sample_inside_and_outside(self) -> None:
|
||||
x = np.array([0.0, 1.0, 2.0])
|
||||
y = np.array([0.0, 1.0, 2.0])
|
||||
z = np.array([[0.0, 1.0, 2.0], [1.0, 2.0, 3.0], [2.0, 3.0, 4.0]])
|
||||
valid = np.ones((3, 3), dtype=bool)
|
||||
sampler = DtmGridSampler(x, y, z, valid)
|
||||
|
||||
zq, vq = sampler.sample_xy(np.array([[0.5, 0.5], [10.0, 10.0]]))
|
||||
self.assertTrue(vq[0])
|
||||
self.assertFalse(vq[1]) # 격자 밖
|
||||
self.assertAlmostEqual(zq[0], 1.0) # 이중선형 보간
|
||||
|
||||
def test_invalid_shape(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
DtmGridSampler(np.array([0.0]), np.array([0.0, 1.0]), np.zeros((2, 1)), np.ones((2, 1)))
|
||||
|
||||
|
||||
class GenerateSectionsTest(unittest.TestCase):
|
||||
def test_straight_route(self) -> None:
|
||||
sampler = CallableSurfaceSampler(lambda xy: 5.0 + 0.05 * xy[:, 0] + 0.02 * xy[:, 1])
|
||||
polyline = [[float(x), 0.0, 5.0 + 0.05 * x] for x in range(0, 101, 10)]
|
||||
|
||||
result = generate_sections(
|
||||
polyline,
|
||||
sampler,
|
||||
SectionGenerationOptions(station_interval_m=20.0, cross_half_width_m=10.0),
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "completed")
|
||||
self.assertAlmostEqual(result["longitudinal"]["length_m"], 100.0)
|
||||
self.assertEqual(result["summary"]["station_count"], 6)
|
||||
self.assertEqual(len(result["cross_sections"]), 6)
|
||||
# 횡단 샘플: ±10m를 0.5m 간격 → 41개
|
||||
self.assertEqual(len(result["cross_sections"][0]["samples"]), 41)
|
||||
# BP/EP 종류 표기
|
||||
self.assertEqual(result["longitudinal"]["stations"][0]["kind"], "bp")
|
||||
self.assertEqual(result["longitudinal"]["stations"][-1]["kind"], "ep")
|
||||
|
||||
def test_invalid_options(self) -> None:
|
||||
sampler = CallableSurfaceSampler(lambda xy: np.zeros(len(xy)))
|
||||
polyline = [[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]]
|
||||
with self.assertRaises(ValueError):
|
||||
generate_sections(polyline, sampler, SectionGenerationOptions(station_interval_m=0.0))
|
||||
|
||||
def test_too_short_polyline(self) -> None:
|
||||
sampler = CallableSurfaceSampler(lambda xy: np.zeros(len(xy)))
|
||||
with self.assertRaises(ValueError):
|
||||
generate_sections([[0.0, 0.0, 0.0]], sampler)
|
||||
@@ -0,0 +1,18 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
|
||||
|
||||
class AtomicWriteJsonTest(unittest.TestCase):
|
||||
def test_atomic_write_json(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
target = Path(temporary_directory) / "nested" / "result.json"
|
||||
value = {"status": "완료", "items": [1, 2, 3]}
|
||||
|
||||
atomic_write_json(target, value)
|
||||
|
||||
self.assertEqual(json.loads(target.read_text(encoding="utf-8")), value)
|
||||
self.assertEqual(list(target.parent.glob("*.tmp")), [])
|
||||
@@ -0,0 +1,63 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from common_util.common_util_workflow import (
|
||||
load_project_workflow,
|
||||
patch_project_workflow_stale,
|
||||
)
|
||||
|
||||
|
||||
class LoadProjectWorkflowTest(unittest.TestCase):
|
||||
def test_load_project_workflow(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
project_root = Path(temporary_directory)
|
||||
|
||||
self.assertEqual(
|
||||
load_project_workflow(project_root),
|
||||
{
|
||||
"current_stage": "scan",
|
||||
"completed": [],
|
||||
"stale_from": None,
|
||||
"stage1_confirmed": None,
|
||||
},
|
||||
)
|
||||
|
||||
saved = {"current_stage": "route", "completed": ["scan"]}
|
||||
(project_root / "workflow.json").write_text(
|
||||
json.dumps(saved, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.assertEqual(load_project_workflow(project_root), saved)
|
||||
|
||||
(project_root / "workflow.json").write_text("[]", encoding="utf-8")
|
||||
with self.assertRaises(ValueError):
|
||||
load_project_workflow(project_root)
|
||||
|
||||
def test_patch_project_workflow_stale(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
project_root = Path(temporary_directory)
|
||||
workflow_path = project_root / "workflow.json"
|
||||
|
||||
patch_project_workflow_stale(project_root, "route")
|
||||
self.assertFalse(workflow_path.exists())
|
||||
|
||||
saved = {
|
||||
"current_stage": "surface",
|
||||
"completed": ["scan"],
|
||||
"stale_from": None,
|
||||
"stage1_confirmed": {"method": "tin"},
|
||||
}
|
||||
workflow_path.write_text(
|
||||
json.dumps(saved, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
patch_project_workflow_stale(project_root, "route")
|
||||
|
||||
updated = json.loads(workflow_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(updated["stale_from"], "route")
|
||||
self.assertEqual(updated["current_stage"], saved["current_stage"])
|
||||
self.assertEqual(updated["completed"], saved["completed"])
|
||||
self.assertEqual(updated["stage1_confirmed"], saved["stage1_confirmed"])
|
||||
@@ -0,0 +1,59 @@
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from common_util.common_util_storage import (
|
||||
get_project_stage_path,
|
||||
resolve_stored_project_path,
|
||||
)
|
||||
from config.config_system import get_project_storage_path
|
||||
|
||||
|
||||
class ProjectStoragePathTest(unittest.TestCase):
|
||||
def test_get_project_storage_path(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as storage_root:
|
||||
with patch("config.config_system.STORAGE_BASE_DIR", storage_root):
|
||||
result = get_project_storage_path("회사", "사용자", "project-id")
|
||||
|
||||
self.assertEqual(
|
||||
result,
|
||||
os.path.join(storage_root, "회사", "사용자", "project-id"),
|
||||
)
|
||||
self.assertTrue(os.path.isdir(result))
|
||||
self.assertNotIn(f"{os.sep}projects{os.sep}", result)
|
||||
|
||||
for invalid_segment in ("", ".", "..", "../escape", "nested/path", "nested\\path"):
|
||||
with self.subTest(invalid_segment=invalid_segment):
|
||||
with self.assertRaises(ValueError):
|
||||
get_project_storage_path(invalid_segment, "사용자", "project-id")
|
||||
|
||||
def test_get_project_stage_path(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as project_root:
|
||||
result = get_project_stage_path(project_root, "B03_FileInput")
|
||||
|
||||
self.assertEqual(result, os.path.join(project_root, "B03_FileInput"))
|
||||
self.assertTrue(os.path.isdir(result))
|
||||
|
||||
for invalid_stage in ("", "B02_ProjRegister", "../escape"):
|
||||
with self.subTest(invalid_stage=invalid_stage):
|
||||
with self.assertRaises(ValueError):
|
||||
get_project_stage_path(project_root, invalid_stage)
|
||||
|
||||
def test_resolve_stored_project_path(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as storage_root:
|
||||
with patch("common_util.common_util_storage.STORAGE_BASE_DIR", storage_root):
|
||||
result = resolve_stored_project_path("storage/company/user/project-id")
|
||||
expected = os.path.join(storage_root, "company", "user", "project-id")
|
||||
|
||||
self.assertEqual(result, expected)
|
||||
self.assertTrue(os.path.isdir(result))
|
||||
|
||||
for invalid_path in ("company/user/project", "../outside", "storage"):
|
||||
with self.subTest(invalid_path=invalid_path):
|
||||
with self.assertRaises(ValueError):
|
||||
resolve_stored_project_path(invalid_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -408,6 +408,32 @@ export const ui_locales = {
|
||||
"지형·포인트클라우드·도면 파일을 업로드하세요.",
|
||||
"Upload terrain, point cloud, and drawing files.",
|
||||
],
|
||||
B03_File_Select_Label: ["입력 파일 선택", "Select input files"],
|
||||
B03_File_Select_Hint: [
|
||||
"LAS/LAZ 1개를 포함해 관련 PRJ, TFW, TIF 또는 도면 파일을 선택하세요.",
|
||||
"Select exactly one LAS/LAZ file with related PRJ, TFW, TIF, or drawing files.",
|
||||
],
|
||||
B03_File_Selected_Title: ["선택한 파일", "Selected files"],
|
||||
B03_File_Selected_Empty: ["선택한 파일이 없습니다.", "No files selected."],
|
||||
B03_File_Upload_Button: ["파일 업로드", "Upload files"],
|
||||
B03_File_Error_Project: [
|
||||
"현재 프로젝트가 선택되지 않았습니다. 프로젝트를 먼저 생성하거나 선택하세요.",
|
||||
"No current project is selected. Create or select a project first.",
|
||||
],
|
||||
B03_File_Error_Required: ["업로드할 파일을 선택하세요.", "Select files to upload."],
|
||||
B03_File_Error_Count: [
|
||||
"한 번에 업로드할 수 있는 파일 수를 초과했습니다.",
|
||||
"Too many files were selected for one upload.",
|
||||
],
|
||||
B03_File_Error_Las: [
|
||||
"LAS 또는 LAZ 파일을 정확히 1개 선택하세요.",
|
||||
"Select exactly one LAS or LAZ file.",
|
||||
],
|
||||
B03_File_Error_Extension: ["허용되지 않은 파일 형식입니다.", "Unsupported file type."],
|
||||
B03_File_Error_Size: ["파일 크기 제한을 초과했습니다.", "File size limit exceeded."],
|
||||
B03_File_Upload_Success: ["입력 파일 업로드를 완료했습니다.", "Input files uploaded."],
|
||||
B03_File_Upload_Failed: ["파일 업로드에 실패했습니다.", "File upload failed."],
|
||||
B03_File_Result_Path: ["저장 경로", "Stored path"],
|
||||
|
||||
/* --- B04_wf1_Surface 지표면 모델 분석 --- */
|
||||
B04_Surface_Title: ["1차 · 지표면 모델 분석", "Step 1 · Surface Analysis"],
|
||||
|
||||
Reference in New Issue
Block a user