This commit is contained in:
2026-07-05 21:27:23 +09:00
parent 23d907265a
commit 3abc2edba6
83 changed files with 10351 additions and 1217 deletions
+132 -28
View File
@@ -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) |
+21 -9
View File
@@ -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만 변경
```
-682
View File
@@ -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
├── 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
│ │ └── ...
│ └── outputs/ ← output_files.stored_path
│ ├── estimation/
│ ├── drawings/
│ ├── reports/
│ └── 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
View File
@@ -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
View File
@@ -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 (종횡단 생성) — 워크플로우 셸
+12
View File
@@ -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 *)"
]
}
}
+6 -1
View File
@@ -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)"
]
}
}
+1
View File
@@ -0,0 +1 @@
{}
+11 -6
View File
@@ -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
+47
View File
@@ -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);
}
}
+60
View File
@@ -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,
}
+86
View File
@@ -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()
+144
View File
@@ -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()
+52
View File
@@ -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]
+178 -19
View File
@@ -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();
}
+117 -6
View File
@@ -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",
});
}
+125
View File
@@ -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
]
+149
View File
@@ -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": "모델 목록 조회 중 오류가 발생했습니다."},
)
+71
View File
@@ -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]
+231 -9
View File
@@ -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);
}
+120
View File
@@ -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 격자에 샘플링해
비용면을 만들고(캐시 재사용), BPCPEP 다구간 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),
},
}
+181
View File
@@ -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,),
)
+133
View File
@@ -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": "경로 확정 처리 중 오류가 발생했습니다."},
)
+102
View File
@@ -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
+55
View File
@@ -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)
+35
View File
@@ -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)
+36
View File
@@ -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
+42
View File
@@ -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
View File
@@ -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.")
+6
View File
@@ -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
View File
@@ -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)
# ─────────────────────────────────────────────────────────────────────────
+516
View File
@@ -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';
+22 -13
View File
@@ -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
View File
@@ -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
+110
View File
@@ -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},
)
+58
View File
@@ -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,
)
+100
View File
@@ -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,
)
+122
View File
@@ -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)
+30
View File
@@ -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)
+64
View File
@@ -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())
+48
View 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)
+25
View File
@@ -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)
+42
View File
@@ -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)
+59
View File
@@ -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)
+61
View File
@@ -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"
)
+143
View File
@@ -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"))
+40
View File
@@ -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])
+64
View File
@@ -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)
+148
View File
@@ -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)
+44
View File
@@ -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)
+51
View File
@@ -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)
+103
View File
@@ -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"])
+71
View File
@@ -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)
+18
View File
@@ -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")), [])
+63
View File
@@ -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"])
+59
View File
@@ -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()
+26
View File
@@ -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"],