This commit is contained in:
2026-07-10 18:12:17 +09:00
parent 50d75fcfcd
commit e3d66e717c
28 changed files with 2542 additions and 99 deletions
+388
View File
@@ -0,0 +1,388 @@
# 0_old vs 현재 프로젝트 혼선 지점 분석 (Critical for Coders)
**작성일:** 2026-07-10
**목적:** 0_old 코드 참고 시 발생할 수 있는 통합 오류 사전 차단
---
## 1. 데이터 구조 & 저장소 경로 차이
### 1.1 저장소 경로 구조
| 항목 | 0_old | 현재 프로젝트 |
|------|-------|------------|
| **기본 경로** | `storage/projects/{projectId}/` | `storage/{company_slug}/{user_slug}/{projectId}/` |
| **원본 입력** | `input/` (명확하지 않음) | `B03_FileInput/input/` |
| **처리 결과** | `processed/` (모든 것을 한곳에) | `B04_wf1_Surface/processed/` (분산) |
| **지표면 모델** | `processed/` 내 JSON | `B04_wf1_Surface/models/` |
| **경로 데이터** | `route_design/` | `B05_wf2_Route/route/` |
| **종횡단면** | `sections/` | `B06_wf3_ProfileCross/` |
**코더 주의:**
- 0_old의 경로를 그대로 현재 코드에 복사하면 **파일을 찾을 수 없음**
- 항상 `config_system.py``PROJECT_STORAGE_STAGE_DIRS` 확인
- 경로 변환은 `common_util_storage.py``resolve_stored_project_path()` 사용
**예시 오류:**
```python
# ❌ 0_old 방식
path = f"storage/projects/{projectId}/processed/tin.json"
# ✅ 현재 방식
from common_util.common_util_storage import resolve_stored_project_path
stored_path = await get_project_storage_relative_path(connection, projectId)
project_root = Path(resolve_stored_project_path(stored_path))
path = project_root / "B04_wf1_Surface" / "models" / "tin.json"
```
---
## 2. API 엔드포인트 & 요청/응답 구조
### 2.1 0_old API 엔드포인트
| 메서드 | 경로 | 목적 |
|--------|------|------|
| GET | `/api/projects/{projectId}/analysis` | 분석 결과 조회 |
| GET | `/api/projects/{projectId}/las-points` | LAS 포인트 샘플 조회 |
| GET | `/api/projects/{projectId}/all-points` | 모든 포인트 조회 |
| GET | `/api/projects/{projectId}/ground-points` | 지면 포인트만 조회 |
| POST | `/api/projects/{projectId}/analyze-ground` | 분석 실행 (동기) |
| GET | `/api/projects/{projectId}/terrain-models` | 지표면 모델 목록 |
| POST | `/api/projects/{projectId}/route/solve` | 경로 계산 |
| POST | `/api/projects/{projectId}/sections/generate` | 종횡단면 생성 |
### 2.2 현재 프로젝트 API 엔드포인트
| 메서드 | 경로 | 목적 |
|--------|------|------|
| POST | `/api/projects/{projectId}/surface/analyze` | 분석 실행 (비동기, **상태 저장**) |
| GET | `/api/projects/{projectId}/surface/status` | 분석 상태 및 진행률 조회 |
| GET | `/api/projects/{projectId}/surface/models` | 지표면 모델 목록 |
| GET | `/api/projects/{projectId}/surface/point-cloud` | 포인트 샘플 조회 |
| GET | `/api/projects/{projectId}/surface/ground-stats` | 지면 필터 통계 |
| POST | `/api/projects/{projectId}/files` | 파일 업로드 (다중) |
| POST | `/api/projects/{projectId}/upload-sessions` | 청크 업로드 세션 시작 |
| POST | `/api/projects/{projectId}/chunks` | 청크 업로드 |
| POST | `/api/projects/{projectId}/finalize` | 업로드 완료 |
**코더 주의:**
- 0_old의 `/api/projects/{projectId}/analyze-ground`**현재에 없음**
- 현재는 `/api/projects/{projectId}/surface/analyze` + `/api/projects/{projectId}/surface/status` 조합
- 0_old 코드 마이그레이션 시 **비동기 처리 방식 변경 필수**
---
## 3. 프론트엔드 기술 스택 차이
### 3.1 라이브러리 버전
| 라이브러리 | 0_old | 현재 프로젝트 | 비고 |
|-----------|-------|------------|------|
| React | 19.2.7 | **없음** | **critical diff** |
| React-DOM | 19.2.7 | **없음** | **critical diff** |
| lucide-react | 1.23.0 | **없음** | 아이콘 라이브러리 |
| THREE.js | 0.185.0 | 0.185.0 | ✅ 동일 |
| MapLibre-GL | 5.24.0 | **없음** | 지도 렌더러 |
| TypeScript | 6.0.3 | 5.x (확인 필요) | 버전 차이 가능성 |
**코더 주의:**
- 0_old의 모든 `.tsx` 컴포넌트는 **React 훅 기반**
- 현재 프로젝트는 **바닐라 TypeScript + DOM 조작**
- 0_old 컴포넌트를 현재 프로젝트에 복사하면 **즉시 오류** (React 불가)
### 3.2 필수 변환 규칙 (0_old → 현재)
#### React 훅 → 클로저 + 렌더 함수
```typescript
// ❌ 0_old (React 훅)
function PointCloudViewer({ projectId }: { projectId: string }) {
const [scale, setScale] = useState<number>(1.0);
useEffect(() => { /* initialization */ }, [projectId]);
return <div>...</div>;
}
// ✅ 현재 (바닐라 TS)
function createPointCloudViewer(projectId: string): HTMLElement {
let scale = 1.0;
const container = document.createElement('div');
const initialize = async () => { /* initialization */ };
initialize();
return container;
}
```
#### JSX → `createElement` (또는 템플릿 리터럴)
```typescript
// ❌ 0_old (JSX)
<button onClick={() => handleClick()}>Click me</button>
// ✅ 현재 (DOM 직접 생성)
const button = document.createElement('button');
button.textContent = 'Click me';
button.addEventListener('click', handleClick);
```
#### lucide-react 아이콘 → 인라인 SVG / 유니코드
```typescript
// ❌ 0_old (lucide-react)
import { FileUp } from 'lucide-react';
<FileUp size={24} />
// ✅ 현재 (유니코드 또는 SVG)
const icon = document.createElement('span');
icon.textContent = '📤'; // 또는 인라인 SVG
```
---
## 4. 분석 엔진 & 백엔드 알고리즘
### 4.1 0_old 분석 프로세스
**`0_old/backend/app/analyzer.py`:**
- `analyze_project()` — 동기식 전체 분석 실행
- LAS 로드 및 메타데이터 추출
- 포인트 클라우드 필터 적용 (CSF, PMF, RANSAC, grid_min_z)
- 지표면 모델 생성 (TIN, DTM, NURBS, implicit, meshfree 등)
- 결과를 `storage/projects/{projectId}/processed/analysis.json` 에 저장
**결과 저장 형식:**
```json
{
"timestamp": "2025-07-10T...",
"project_files": { /* file metadata */ },
"point_cloud_summary": { "total": 1000000, ... },
"ground_filter_results": { "csf": { ... }, "pmf": { ... }, ... },
"terrain_models": {
"tin": { "model_file": "...", "contours": [...] },
"dtm": { ... }
}
}
```
### 4.2 현재 프로젝트 분석 프로세스
**`B04_wf1_Surface/B04_wf1_Surface_Engine.py`:**
- `run_surface_analysis()` — 동기식이지만, 라우터에서 `asyncio.to_thread()` 로 실행
- 결과를 여러 개의 DB 테이블에 저장:
- `processed_point_clouds` 테이블
- `surface_models` 테이블
- `terrain_layers` 테이블
- 파일 경로도 함께 DB에 저장 (`model_file_path`, `layer_file_path` 등)
**코더 주의:**
- 0_old는 JSON 파일 중심, 현재는 **DB + 파일 경로 이원화**
- 분석 결과 조회 시:
- 0_old: `storage/projects/{projectId}/processed/analysis.json` 직접 읽기
- 현재: `surface_models` 테이블 쿼리 + 필요한 파일을 경로로 로드
---
## 5. 프론트엔드 상태 관리 & LocalStorage
### 5.1 0_old 상태 관리
**`main.tsx`에서 `useState` 사용:**
- `projectId` — 현재 프로젝트 ID
- `stage` — 현재 워크플로우 단계
- `analysisResult` — 분석 결과 캐시
- `selectedRoute` — 선택된 경로
- 등등
**localStorage 미사용** (또는 매우 제한적)
### 5.2 현재 프로젝트 상태 관리
**`config_frontend.ts`:**
```typescript
const CURRENT_PROJECT_ID_KEY = "frd_current_project_id";
```
**localStorage 의존성:**
- 프로젝트 ID는 반드시 localStorage에 저장
- 페이지 네비게이션 시 projectId 복구
- 청크 업로드 세션 정보도 localStorage 유지
**코더 주의:**
- 0_old 코드를 현재 프로젝트에 옮길 때, localStorage 복구 로직 추가 필수
- 특히 B04, B05, B06 페이지는 모두 `CURRENT_PROJECT_ID_KEY` 의존
---
## 6. 3D 렌더링 (THREE.js) — 유사성
### 6.1 공통점
**0_old와 현재 모두 THREE.js 0.185.0 사용:**
- `PointCloudViewer.tsx` → 포인트 클라우드 렌더링
- `TerrainModelViewer.tsx` → 지표면 모델 렌더링
- OrbitControls 사용 (카메라 조작)
**코더에게 좋은 소식:**
- THREE.js 부분은 상대적으로 간단한 변환 가능
- 렌더 루프, 조명, 카메라 로직은 큰 변경 불필요
- DOM 생성 부분만 수정하면 대부분 재사용 가능
**예시:**
```typescript
// ❌ 0_old (JSX)
<Canvas camera={{ position: [100, 100, 100] }}>
<mesh>...</mesh>
</Canvas>
// ✅ 현재 (THREE.js 직접)
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, width/height, 0.1, 10000);
camera.position.set(100, 100, 100);
const renderer = new THREE.WebGLRenderer();
renderer.render(scene, camera);
```
---
## 7. 필터 알고리즘 (Python) — 재사용 가능
### 7.1 0_old 필터 유틸리티
**`0_old/utils/` 디렉토리:**
- `filter_csf.py` — Cloth Simulation Filter
- `filter_pmf.py` — Progressive Morphological Filter
- `filter_ransac.py` — RANSAC-기반 필터
- `filter_grid_min_z.py` — Grid Min-Z 필터
**현재 프로젝트에서 이 필터들을 사용하는가?**
- ✅ 네, `B04_wf1_Surface_Engine.py`에서 참고
- 동일한 필터 알고리즘 재구현 가능
**코더 주의:**
- 필터 알고리즘은 그대로 복사 가능 (Python 코드)
- 하지만 **경로, DB 저장 방식은 변경 필수**
---
## 8. 라우팅 & 네비게이션 차이
### 8.1 0_old 라우팅
**`main.tsx`에서 React Router 미사용, 수동 state 관리:**
```typescript
if (stage === "TERRAIN_ANALYSIS") {
return <TerrainAnalysisStage />;
} else if (stage === "ROUTE_DESIGN") {
return <RouteDesignStage />;
}
// ...
```
### 8.2 현재 프로젝트 라우팅
**`A00_Common/A00_Common_UI_Navigator.ts`에서 수동 SPA 라우팅:**
```typescript
function navigateTo(route: string): void {
window.location.hash = route;
// ...
}
```
**코더 주의:**
- 0_old 코드를 현재에 적용할 때, `navigateTo()` 사용 필수
- localStorage 저장 + 네비게이션 순서 엄격 준수 (§2.2.2 참고)
---
## 9. 체크리스트 for Coders
0_old 코드를 현재 프로젝트에 통합할 때 반드시 확인:
### 파일 경로
- [ ] `storage/projects/...``storage/{company}/{user}/{projectId}/B0N_...` 변환
- [ ] 하드코딩된 경로 제거
- [ ] `resolve_stored_project_path()``PROJECT_STORAGE_STAGE_DIRS` 사용
### API 호출
- [ ] 엔드포인트 변경 (`/analyze-ground``/surface/analyze`)
- [ ] 비동기 처리 추가 (폴링 로직)
- [ ] 요청/응답 포맷 확인
### 프론트엔드 컴포넌트
- [ ] React 훅 → 클로저 + 렌더 함수 변환
- [ ] JSX → `createElement` 또는 템플릿 리터럴 변환
- [ ] lucide-react 아이콘 → 유니코드/인라인 SVG 변환
- [ ] localStorage 의존성 추가 (`CURRENT_PROJECT_ID_KEY`)
### 상태 관리
- [ ] `useState` 호출 제거
- [ ] localStorage에 상태 저장 확인
- [ ] 페이지 리로드 후 복구 로직 확인
### THREE.js 렌더링
- [ ] 캔버스 생성 부분 확인
- [ ] 렌더 루프 설정 확인
- [ ] 이벤트 리스너(resize, click 등) 추가
### DB 저장
- [ ] JSON 파일 저장에서 DB 테이블 저장으로 변경
- [ ] 파일 경로를 DB에 함께 저장
- [ ] 트랜잭션 처리 확인
---
## 10. 예시: 0_old PointCloudViewer 변환
### 0_old (React + JSX)
```typescript
// 0_old/frontend/src/PointCloudViewer.tsx (간략)
export function PointCloudViewer({ projectId }: { projectId: string }) {
const [scale, setScale] = useState(1.0);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
const renderer = new THREE.WebGLRenderer({ canvas });
// 렌더링 설정
}, [projectId]);
return <canvas ref={canvasRef} />;
}
```
### 현재 프로젝트 (바닐라 TS)
```typescript
// B04_wf1_Surface/B04_wf1_Surface_UI_PointCloud.ts (신규)
export function createPointCloudViewer(projectId: string): HTMLElement {
let scale = 1.0;
const canvas = document.createElement('canvas');
const initialize = async () => {
const renderer = new THREE.WebGLRenderer({ canvas });
// 렌더링 설정
const response = await fetch(`/api/projects/${projectId}/surface/point-cloud`);
const data = await response.json();
// 포인트 클라우드 렌더
};
initialize();
return canvas;
}
```
---
## 11. 결론
| 영역 | 재사용 난이도 | 주의사항 |
|------|-----------|---------|
| **경로 구조** | 높음 | 전체 매핑 필요 |
| **API 엔드포인트** | 높음 | 비동기 처리 방식 변경 |
| **프론트엔드 컴포넌트** | 높음 | React → TS 변환 필수 |
| **필터 알고리즘** | 낮음 | 경로 변수만 수정 |
| **THREE.js 렌더링** | 낮음 | JSX → DOM 생성만 수정 |
| **DB 저장 로직** | 높음 | 완전 재작성 |
**최종 권장사항:**
1. 0_old는 **알고리즘 참고용**으로만 사용
2. 경로, API, 컴포넌트 구조는 **현재 프로젝트 표준 준수**
3. 통합 전 **단위 테스트** 작성 (경로 매핑, API 응답 검증)
+800
View File
@@ -0,0 +1,800 @@
# WF1~WF3 레이아웃 재설계 및 파일 업로드 프로세스 개선 계획서
**작성일:** 2026-07-10 (업데이트)
**상태:** 상세 설계 단계
**범위:** B03_FileInput (파일 업로드 비동기 처리), 대시보드 워크플로우 상태 관리, B04_wf1_Surface~B06_wf3_ProfileCross 공통 레이아웃 템플릿 (0_old 기반)
---
## 1. 개요 및 개선 배경
### 1.1 현재 문제점
1. **B03 파일 업로드**
- 업로드 시작 후 완료될 때까지 UI 로딩 오버레이 활성화 상태 유지 → 사용자 동작 불가
- 페이지 리프레시 시 업로드 진행상황 소실
- 백엔드는 비동기 처리 중인데 프론트엔드는 동기 대기
2. **B01 대시보드 워크플로우 상태**
- 파일 업로드 완료 후 WF1 분석 완료되어도 워크플로우 버튼이 활성화되지 않음
- 대시보드의 프로젝트 상태 변경 로직 미동기화
3. **B04_wf1_Surface, B05_wf2_Route, B06_wf3_ProfileCross 레이아웃**
- 현재 3단 레이아웃(상단+좌측+우측)이 협소함
- 좌측 폼 영역이 항상 표시되어 우측 작업 영역을 제한
- 3D 조작, 경로 설계, 종단/횡단 작업 같은 실무에는 넓은 공간 필수
- 현재 B04 페이지는 카드형 제목만 표시 (불완전)
4. **0_old 프로그램 기반 구현**
- 0_old/frontend/src에 PointCloudViewer.tsx, TerrainModelViewer.tsx, RouteDesignStage.tsx, LongitudinalProfile.tsx, CrossSectionGrid.tsx 등 완성된 컴포넌트 있음
- 0_old/utils에 필터 적용(CSF, PMF, RANSAC, grid_min_z), 경로 계산(dijkstra, ridge_valley), 종횡단 생성 로직 있음
- 0_old/backend/app에 분석 엔진 있음 (분석 결과를 `storage/projects/{projectId}/processed/` 에 JSON/NPZ 형식으로 저장)
- **현재 프로젝트와 통합:** 레이아웃(데스크톱 IDE 스타일)과 테마(Wiza 디자인)는 새로 작성, 콘텐츠와 3D 렌더링은 0_old 참고/재사용
---
## 2. 세부 개선 사항
### 2.1 B03_FileInput 업로드 비동기 처리 개선
#### 2.1.1 현재 상태 (코드 분석)
- 파일 선택/드래그 → 유효성 검증 → 청크 업로드 반복
- `uploadOneFile()` 함수 내 루프에서 모든 청크 업로드 완료 대기
- `showLoadingOverlay()` / `hideLoadingOverlay()` 호출로 UI 블로킹
- 백엔드 `pollWF1Analysis()` 함수로 분석 상태 폴링 (5초 주기, 최대 30분)
#### 2.1.2 개선 계획
**2.1.2.1 파일 업로드 진행 상태 UI 표시 (로딩 오버레이 제거)**
- 로딩 오버레이 제거 → 사용자는 자유롭게 다른 페이지 이동 가능
- 대신 **지속적인 업로드 상태 표시:**
- 각 파일 카드에 진행률 바 및 상태 배지 표시 (이미 구현됨)
- 우상단 토스트: "파일 업로드 중... (60% 완료)" 지속 표시
- 분석 시작 후 토스트 업데이트: "WF1 분석 중... (30% 진행률)"
**2.1.2.2 업로드 세션 정보 localStorage에 지속 저장 + 분석 진행률 함께 저장**
- 현재: 업로드 완료 시 `localStorage.removeItem(storageKey)` 실행 → 페이지 리프레시 시 정보 소실
- 개선: 업로드 완료 후에도 세션 정보 유지 (별도 키: `b03_project_state_${projectId}`)
- 저장 형식:
```json
{
"projectId": "uuid",
"uploadedAt": 1720599600000,
"files": [
{
"slot": "las_laz",
"fileName": "data.las",
"fileSize": 1024000000,
"status": "completed"
}
],
"analysisStatus": "in_progress", // pending | in_progress | completed | failed
"analysisProgress": 30,
"lastCheckedAt": 1720599630000
}
```
- **페이지 리로드 후 복구 로직:**
- localStorage에서 저장된 정보 로드
- 파일 카드들을 "완료" 상태로 표시
- 분석이 진행 중이면 토스트 메시지 "WF1 분석이 계속 진행 중입니다..." 표시
- 백그라운드에서 분석 진행률 폴링 재개
**2.1.2.3 분석 진행 상태 추적 메커니즘**
- **0_old의 구조 분석:**
- 0_old/backend/app/analyzer.py의 `analyze_project()` 함수는 분석을 동기로 실행
- 결과를 `storage/projects/{projectId}/processed/analysis.json` 에 저장
- 프론트엔드는 `/api/projects/{projectId}/analysis` 로 분석 결과를 조회
- **개선 방식 (⚠️ 신규 API 생성 금지 — 기존 API 확장):**
- **기존 API 활용:** `GET /api/projects/{project_id}/surface/status` 가 이미 존재함
(B04_wf1_Surface_Router.py `get_wf1_analysis_status()`, 프론트 클라이언트는 B03_FileInput_Api_Fetch.ts `checkWF1AnalysisStatus()`)
- **기존 구현의 한계 (개선 대상):**
- 현재는 `surface_models` 테이블의 모델 카운트만으로 completed/in_progress 판정
- 분석이 **실패해도 영원히 in_progress** 로 표시됨 (failed 상태 판별 불가)
- 진행률(%) 정보 없음
- **확장 계획:** 기존 엔드포인트 응답에 필드 추가 (경로 변경 없음)
```json
{
"project_id": "uuid",
"status": "in_progress", // pending | in_progress | completed | failed
"model_count": 0,
"progress_percent": 30,
"current_stage": "filter_csf",
"message": "CSF 필터 적용 중"
}
```
- **진행률 기록 위치:** 분석 실행 측(B03_FileInput_Engine_Analyze.py)이 진행 단계마다 진행률을 기록하고, status API가 이를 읽어 응답. 기록 방식은 기존 `common_util_workflow.py` 의 workflow.json 메커니즘(backend.md §5, 원자적 쓰기)을 우선 검토하고, 부적합 시 프로젝트 저장소 루트(`storage/{회사slug}/{사용자slug}/{projectId}/B04_wf1_Surface/`) 하위에 진행률 JSON 파일 저장. **`storage/{projectId}/...` 같은 축약 경로는 실제 구조와 다르므로 사용 금지.**
- 폴링 주기: 기존 B03 코드의 5초 유지 (backend.md §5.3은 workflow 폴링 3초를 규정하므로, 코더는 구현 시 두 값을 config_frontend 상수로 통일할 것)
- 사용자 이탈 대응: 페이지 복귀 시 localStorage 복구 + status API 재폴링으로 진행률 재표시
**2.1.2.4 프로젝트 상태 DB 업데이트 (대시보드 워크플로우 버튼 활성화)**
- 현재: 파일 업로드 → 분석 완료 자동 이동만 처리
- 개선:
- B03_FileInput_Engine_Analyze.py에서 각 분석 단계마다 `projects.status` 업데이트:
- 업로드 완료: `status = 'WF1_ANALYZING'`
- 분석 완료: `status = 'WF1_COMPLETE'`
- DB 업데이트 로직:
```python
await db.execute(
"UPDATE projects SET status = %s, updated_at = NOW() WHERE id = %s",
('WF1_COMPLETE', project_id)
)
```
- 대시보드 워크플로우 테이블에서 프로젝트별 `status` 필드를 읽어 버튼 활성화/비활성화 결정
#### 2.1.3 구현 파일 수정
- **B03_FileInput_UI_Page.ts**
- `startChunkedUpload()` 함수: 로딩 오버레이 제거
- `uploadOneFile()` 함수: 백그라운드 진행 표시로 전환
- 세션 저장 로직 개선 (완료 후 분석 상태 포함)
- 페이지 리로드 시 `detectPausedUploads()` 함수에서 세션 상태 복구
- **B03_FileInput_Api_Fetch.ts**
- 분석 진행률 조회 API 추가: `async function getWF1AnalysisProgress(projectId: string)`
- **B03_FileInput_Engine_Analyze.py**
- 분석 완료 후 `projects.status` 업데이트
- 이메일 발송 후 대시보드 반영 시간 ~5초 이내
---
### 2.2 B01 대시보드 워크플로우 버튼 — 활성화 및 프로젝트 컨텍스트 전달
#### 2.2.1 현재 상태 (코드 검증 완료 — 기존 계획서 v1의 오류 정정)
**버튼 활성화 메커니즘은 이미 존재함:**
- `B01_Dashboard_Repository.py` `_stage_from_status()` (line 20): `projects.status` 문자열에서 stage 번호 계산
- 예: status에 "WF1" 포함 → stage 2, "FILE_UPLOADED" → stage 1, "NEW" → stage 0
- `B01_Dashboard_UI_Page.ts` `workflow()` (line 370): stage 기반으로 B03~B09 버튼 활성/비활성 렌더링
- `enabled = index + 1 <= Math.max(activeStage, 1)` → B03은 항상 활성
**따라서 "버튼이 활성화되지 않는" 실제 원인은 UI가 아니라:**
1. **백엔드가 분석 완료 후 `projects.status`를 갱신하지 않음** → §2.1.2.4에서 해결 (`WF1_ANALYZING` → `WF1_COMPLETE`)
2. 대시보드가 열려 있는 동안 상태 변화를 다시 가져오지 않음 (새로고침해야 반영)
**추가 발견 — 토큰 매칭의 허점:**
- `_stage_from_status()`는 부분 문자열 매칭이므로 `WF1_ANALYZING`과 `WF1_COMPLETE`가 **모두 "WF1" 토큰에 걸려 stage 2**가 됨
- 즉 분석이 진행 중(ANALYZING)이어도 B04 버튼이 열림 → **`_COMPLETE` 접미사까지 확인하도록 수정 필요** (ANALYZING은 직전 단계까지만 활성)
#### 2.2.2 🐞 버그: 워크플로우 버튼으로 진입 시 "현재 프로젝트가 선택되지 않았습니다" 표시
**증상:** 대시보드에서 기존 프로젝트의 B03 버튼 클릭 → 파일 입력 페이지 하단에
"현재 프로젝트가 선택되지 않았습니다. 프로젝트를 먼저 생성하거나 선택하세요." (`ui_template_locale.ts:531` `B03_File_Error_Project`)
**원인 (검증 완료):**
- B03~B06 페이지는 모두 `localStorage.getItem(CURRENT_PROJECT_ID_KEY)` (`"frd_current_project_id"`) 로 프로젝트를 식별
- B03: `B03_FileInput_UI_Page.ts:182` → 값이 비어 있으면 `validateSlots()`가 line 395에서 위 에러 메시지 반환
- 이 키를 **설정하는 곳은 B02 프로젝트 등록 페이지 단 한 곳** (`B02_ProjRegister_UI_Page.ts:154`, 신규 생성 직후)
- 대시보드 워크플로우 버튼의 클릭 핸들러(`B01_Dashboard_UI_Page.ts:391`)는 `navigateTo(route)` 만 호출하고 **프로젝트 ID를 저장하지 않음**
- 심지어 `workflow(activeStage: number)` 함수는 stage 숫자만 받아서 **프로젝트 ID가 함수 스코프에 아예 없음** (호출부 line 362는 행별 `project` 객체를 갖고 있음)
- 결과: 신규 생성 직후가 아닌 경로(기존 프로젝트 진입, localStorage 삭제, 다른 브라우저)에서는 항상 에러
**수정 계획:**
1. `workflow()` 시그니처 변경: `workflow(activeStage: number)` → `workflow(project: ProjectItem)` (또는 `projectId` 추가 인자)
2. 버튼 클릭 핸들러에서 이동 전 프로젝트 ID 저장:
```typescript
button.addEventListener("click", () => {
localStorage.setItem(CURRENT_PROJECT_ID_KEY, project.id);
navigateTo(route);
});
```
3. B03/B04/B05/B06은 수정 불필요 (이미 localStorage에서 읽는 구조이므로 진입 시 자연 해결)
4. 참고: backend.md §5.1의 다중 브라우저 원칙상 localStorage는 브라우저별 격리 — 대시보드 버튼이 유일한 진입점이므로 클릭 시점 저장이 올바른 위치. (장기적으로 라우트 해시에 projectId를 포함하는 방식도 가능하나 라우터 전면 수정이 필요하므로 이번 범위에서 제외)
#### 2.2.3 구현 파일 수정
- **B01_Dashboard_UI_Page.ts**
- `workflow()` 함수: 프로젝트 ID를 받아 클릭 시 `CURRENT_PROJECT_ID_KEY` 저장 (§2.2.2)
- 분석 진행 중인 프로젝트가 있을 때만 목록 재조회 폴링 (5초) — 상태 변화 자동 반영
- **B01_Dashboard_Repository.py**
- `_stage_from_status()`: `_COMPLETE` 접미사 구분 로직 추가 (ANALYZING 상태에서 다음 단계 버튼 미개방)
- 신규 API 불필요 — 기존 프로젝트 목록 API 재사용 (workflow_stage 필드 이미 포함)
---
### 2.3 WF1~WF6 공통 레이아웃 템플릿 설계
#### 2.3.1 목표
- **오프라인 프로그램 스타일 인터페이스** (AutoCAD, QGIS 같은 데스크톱 프로그램의 레이아웃)
- 헤더 숨김/표시 토글 기능
- 좌측 패널 (메뉴 + 설정) = 오버레이 구조로 토글 가능
- 우측 대형 캔버스/뷰 영역 (3D 렌더링, 도면, 테이블 등)
#### 2.3.2 레이아웃 구조
```
┌─────────────────────────────────────────────────────────┐
│ [≡] 로고 | Aislo 프로젝트명 | WF 진행률 | 설정 | 언어 | 로그아웃 │ ← 헤더 (숨기기 가능)
└─────────────────────────────────────────────────────────┘
│ │
│ [▶] ◄── 드롭다운 화살표 (헤더 숨김 시 표시) │
│ │
│ ┌─────────────┐ ┌──────────────────────────────────────┐ │
│ │ 메뉴패널 │ │ │ │
│ │ (토글) │ │ 메인 작업 영역 │ │
│ │ │ │ (3D 뷰어, 표, 그래프 등) │ │
│ │ WF1: 지표면 │ │ │ │
│ │ - 필터 설정 │ │ [← 이 영역이 전체 브라우저 크기의│ │
│ │ - 모델 선택 │ │ 좌우상하 8% 정도 여유만 남기고 │ │
│ │ - 실행 │ │ 전부 사용] │ │
│ │ │ │ │ │
│ │ WF2: 경로 │ │ │ │
│ │ - 경로 매개 │ │ │ │
│ │ ... │ │ │ │
│ └─────────────┘ └──────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
```
#### 2.3.3 상세 스펙
**2.3.3.1 헤더 (createWorkflowShell 확장)**
- 현재: 페이지 타이틀 + 스텝바 표시
- 개선:
- 좌상단: `[≡]` 메뉴 토글 버튼 추가 (아이콘)
- 중앙: 프로젝트명 + 현재 WF 단계 표시
- 우상단: 기존대로 (설정, 언어, 로그아웃)
- 숨기기 기능: 헤더 전체를 슬라이드업 (CSS 애니메이션)
- 헤더 숨김 상태: 중앙에 `[▶]` 화살표 표시 → 클릭하면 드롭다운 메뉴 표시
**2.3.3.2 좌측 메뉴 패널 (오버레이 구조)**
- 고정 너비: 240px (닫힌 상태 시 0px)
- 오버레이 Z-index: 1000
- 콘텐츠:
- WF1: 필터 선택 (grid_min_z, csf, pmf, ransac)
- WF1: 모델 방식 선택 (tin, dtm, nurbs, implicit, meshfree)
- WF1: 강제 재계산 체크박스
- WF1: 실행 버튼
- (다른 WF는 각자 필요한 설정)
- 토글 방식:
- 헤더의 `[≡]` 클릭 → 패널 슬라이드인/아웃 (CSS 트랜지션)
- 또는 패널 바깥 클릭 → 패널 닫기
- 스타일: 디자인 가이드 `.agent/design.md` 준수
- **⚠️ frontend.md §1 규칙: CSS에 hex 코드 직접 입력 금지.** 아래 hex는 참고용이며, 실제 코드는 반드시 `ui_template/ui_template_theme.css` 에 선언된 CSS 변수만 참조할 것. design.md의 토큰이 theme.css에 없으면 theme.css에 변수를 먼저 추가한 뒤 사용
- 배경: paper 계열 (참고: #f6f7fa)
- 텍스트: charcoal 계열 (참고: #333333)
- 섹션 타이틀: Inter 500 14px
- 섹션 간 구분: mist 계열 1px 보더 (참고: #e6e2e3)
- 폼 요소: 기존 `createInputField`, `createButton` 재사용. **체크박스 공통 컴포넌트는 현재 없음** (frontend.md §2.1 목록에 부재) — B04 기존 코드의 `buildCheckboxGroup()` 패턴을 `ui_template_elements.ts` 로 승격하여 공통화 권장
**2.3.3.3 메인 작업 영역 (우측)**
- 전체 브라우저 크기에서 좌우상하 8% 여유 남기고 차지
- 배경: `--color-canvas` (#ffffff)
- 콘텐츠별 렌더링:
- WF1: 3D 포인트클라우드 뷰어 (0_old의 PointCloudViewer.tsx 참고)
- WF1: 모델 카드 그리드 (선택 가능)
- WF2: 경로 설계 맵/캔버스
- WF3: 종단면/횡단면 2D 뷰어
- (다른 WF는 각자 콘텐츠)
- 반응형 레이아웃: 메뉴 토글 시 우측 영역 자동 너비 조정
#### 2.3.4 레이아웃 템플릿 구현 파일
- **ui_template/ui_template_workflow_layout.ts** (신규)
- 함수: `createWorkflowLayout(options: WorkflowLayoutOptions)`
- 옵션:
```typescript
interface WorkflowLayoutOptions {
title: string; // 페이지 타이틀
steps: string[]; // 워크플로우 단계들
activeStep: number; // 현재 단계 인덱스
leftPanel: HTMLElement; // 좌측 설정 패널
mainContent: HTMLElement; // 우측 메인 콘텐츠
onHeaderToggle?: (isVisible: boolean) => void; // 헤더 토글 콜백
onMenuToggle?: (isOpen: boolean) => void; // 메뉴 토글 콜백
}
```
- 반환: 완성된 DOM 구조 + 토글 컨트롤 함수
- 내부 스타일: 별도 CSS 파일 `ui_template/ui_template_workflow_layout.css` 에서 관리
---
### 2.4 B04_wf1_Surface, B05_wf2_Route, B06_wf3_ProfileCross 페이지 재구현 (0_old 기반)
#### 2.4.1 0_old 프로그램 분석
**데이터 흐름 (0_old):**
1. LAS 파일 업로드 → `storage/projects/{projectId}/raw/` 저장
2. `analyze_project(projectId)` 실행:
- LAS 데이터를 컴퓨터가 읽기 좋은 형태로 변환 (NumPy arrays, JSON)
- `storage/projects/{projectId}/processed/` 에 저장:
- `las-points-sample.json` — 샘플링된 포인트 (3D 뷰어용)
- `analysis.json` — 메타데이터 (bounds, CRS, point_count 등)
3. 필터 선택 (grid_min_z, csf, pmf, ransac) → 지면 포인트 추출
- `storage/projects/{projectId}/processed/ground-stats.json` 저장
4. 모델 방식 선택 (tin, dtm, nurbs, implicit, meshfree) → 지표면 생성
- 각 모델별 결과: `storage/projects/{projectId}/processed/{model_type}.json`
5. 프론트엔드:
- PointCloudViewer.tsx: 3D 포인트클라우드 렌더링 (WebGL/THREE.js)
- TerrainModelViewer.tsx: 지표면 모델 3D 렌더링
**구현 요소:**
- 0_old/frontend/src:
- `PointCloudViewer.tsx` — 포인트클라우드 3D 뷰어
- `TerrainModelViewer.tsx` — 지표면 모델 뷰어
- `RouteDesignStage.tsx` — WF2 경로 설계 (Dijkstra, Ridge-Valley 알고리즘)
- `LongitudinalProfile.tsx` — WF3 종단면 2D 그래프
- `CrossSectionGrid.tsx` — WF3 횡단면 2D 그래프
- `sectionTypes.ts` — 종횡단 데이터 타입
- 0_old/utils:
- `filter_*.py` (CSF, PMF, RANSAC, grid_min_z) — 지면 필터
- `terrain_model_converter.py` — 지표면 모델 생성
- `section_generator.py` — 종횡단 생성
- `route_solver*.py` — 경로 계산
#### 2.4.2 현재 프로젝트에서의 재사용 방식
**레이아웃:** 새 작성 (데스크톱 IDE 스타일 + Wiza 테마)
**콘텐츠 & 3D 렌더링:** 0_old 코드 참고/재사용
**데이터:** 백엔드에서 동일한 구조로 저장
**⚠️ 필수 제약 1 — React → Vanilla TS 전환:**
- 0_old 프론트엔드는 **React 19 + JSX(.tsx) + lucide-react 아이콘** 기반
- 현재 프로젝트는 **React 미사용** (package.json에 react 없음 — three, maplibre-gl만 존재). 순수 TypeScript DOM 조작 방식 (`document.createElement`, `createButton` 등)
- 따라서 0_old 컴포넌트는 **절대 그대로 복사 불가**. 다음 규칙으로 재작성:
- React 훅(`useState`, `useEffect`, `useRef`) → 클로저 변수 + 명시적 렌더 함수로 변환 (B03/B04 기존 페이지 코드의 패턴 참고)
- JSX → `document.createElement` + `append` 방식
- `lucide-react` 아이콘 → 사용 불가. 인라인 SVG 문자열 또는 유니코드 기호로 대체 (B03의 `icon: "●"` 패턴 참고)
- THREE.js 씬 구성 로직(카메라, OrbitControls, BufferGeometry 등)은 React와 무관하므로 **거의 그대로 재사용 가능** — 0_old에서 가장 가치 있는 부분
- 0_old의 API 호출부(`fetch(API_BASE...)`)는 현재 프로젝트의 `Api_Fetch.ts` 패턴(credentials: "include", 타임아웃, 오류 변환)으로 교체
**⚠️ 필수 제약 2 — 백엔드 엔진은 이미 이식 완료:**
- 0_old/utils의 필터·모델 로직은 **이미 B04_wf1_Surface_Engine_*.py 로 이식되어 존재함**
(Filter_Grid/CSF/PMF/RANSAC, ModelBuild, Smooth, Contour, Pipeline, Structurize 모두 존재 확인됨)
- 코더는 백엔드 분석 로직을 **재이식하지 말 것**. 필요한 백엔드 작업은:
1. `/surface/status` 응답 확장 (진행률)
2. `/surface/point-cloud`, `/surface/ground-stats` 신규 엔드포인트 (기존 Engine 산출물을 읽어 반환)
3. `projects.status` 갱신 로직
- WF2/WF3 백엔드(route_solver, section_generator)는 아직 미이식 → Phase 5/6에서 0_old/utils 참고해 이식
**⚠️ 필수 제약 3 — 700줄 제한 (CLAUDE.md):**
- PointCloudViewer 등 대형 컴포넌트 이식 시 700줄 초과 가능성 높음
- 사전에 파일 분할 설계: 예) `B04_wf1_Surface_UI_Viewer.ts` (3D 씬), `B04_wf1_Surface_UI_ViewerControls.ts` (조작 패널) 분리 후 structure.md 갱신
**진행 방식:**
1. 0_old의 각 컴포넌트를 위 제약에 따라 vanilla TS로 재작성 (파일명 규칙: `[폴더명]_[기능].ts`)
2. 백엔드는 기존 B04 Engine 재사용 + API 확장만 수행
3. 레이아웃 템플릿만 새로 작성 (모든 WF에서 재사용)
#### 2.4.3 B04_wf1_Surface 재구현 계획
**사용자 흐름:**
```
[좌측 메뉴] [우측 메인 영역]
┌────────────────┐ ┌──────────────────────┐
│ WF1 지표면 │ │ 1. 포인트클라우드 │
│ ───────────── │ ──────→ │ 3D 뷰어 │
│ 필터 선택: │ │ 2. 필터 적용 후 │
│ ☑ grid_min_z │ │ 지표면 모델 선택 │
│ ☑ CSF │ │ (카드: TIN, DTM..│
│ ☑ PMF │ │ 3. 모델 상세 뷰 │
│ ☑ RANSAC │ │ (해상도, 파일크기│
│ │ │ │
│ 모델 선택: │ │ [▼] 다음 단계 진행 │
│ ☑ TIN │ │ │
│ ☑ DTM │ └──────────────────────┘
│ ☑ NURBS │
│ ☑ Implicit │
│ ☑ Meshfree │
│ │
│ [분석 실행] │
│ (또는 진행률) │
└────────────────┘
```
**구현:**
1. **좌측 메뉴 패널:**
- 필터 체크박스 (0_old: grid_min_z, csf, pmf, ransac)
- 모델 선택 체크박스 (0_old: tin, dtm, nurbs, implicit, meshfree)
- "강제 재계산" 체크박스
- "분석 실행" 버튼 (또는 진행률 표시)
2. **우측 메인 영역 (탭 또는 스테이지 표시):**
- **탭 1: 포인트클라우드 뷰어**
- 0_old의 PointCloudViewer.tsx 재사용
- 밀도 조절, 포인트 크기, 축 표시/숨기기 기능
- **탭 2: 필터 결과 선택**
- 각 필터별 포인트 통계 표시
- 선택한 필터에 대해 "분석 실행"
- **탭 3: 지표면 모델 선택**
- 모델 카드 그리드:
- 모델 이름 (TIN, DTM, ...)
- 상태 배지 (완료/진행 중)
- 선택 버튼
- 선택한 모델의 상세 정보:
- 해상도, 생성 시간, 파일 크기
- 3D 미리보기 (TerrainModelViewer.tsx)
- **탭 4: 분석 진행 중 (선택사항)**
- 진행률 바, 현재 단계, ETA
3. **API & 백엔드 (§4 표 참조 — 기존 API 재사용 우선):**
- `POST /surface/analyze` — ✅ 기존 (분석 실행: 필터 + 모델)
- `GET /surface/models` — ✅ 기존 (모델 목록)
- `GET /surface/status` — ✅ 기존, 진행률 필드 확장
- `GET /surface/point-cloud` — 🆕 신규 (포인트클라우드 샘플)
- `GET /surface/ground-stats` — 🆕 신규 (필터별 통계)
4. **UX 개선 (기존 B04의 문제 해결):**
- 현재 B04는 사용자가 `input_file_id`를 숫자로 직접 입력해야 함 → **자동화**: 페이지 진입 시 프로젝트의 input_files 목록을 조회해 LAS 파일을 자동 선택 (사용자 직접 입력 제거)
- B03 업로드 직후 백엔드가 자동으로 분석을 실행하므로(B03_FileInput_Engine_Analyze.py), B04의 "분석 실행" 버튼은 **재분석(force) 용도**임을 UI에 명시 (예: 버튼명 "재분석 실행")
#### 2.4.4 B05_wf2_Route 재구현 계획
**사용자 흐름:**
```
[좌측 메뉴] [우측 메인 영역]
┌────────────────┐ ┌──────────────────────┐
│ WF2 경로 설계 │ │ 지표면 3D 뷰 │
│ ───────────── │ ──────→ │ + 경로 선 그려짐 │
│ 시작점(B점) │ │ │
│ 끝점(E점) │ │ [마우스: 경로점 │
│ 중간점(C점) │ │ 클릭하여 추가] │
│ 도움점(A점) │ │ │
│ 피해회피점(F점) │ [▼] 다음 단계 진행 │
│ │ └──────────────────────┘
│ 알고리즘: │
│ ○ Dijkstra │
│ ○ Ridge-Valley
│ │
│ 임도 등급: │
│ ○ 간선임도 │
│ ○ 지선임도 │
│ ○ 작업임도 │
│ │
│ 최소 곡선반경: │
│ [______] m │
│ │
│ 포장 여부: │
│ ☐ 포장 │
│ │
│ [경로 계산] │
└────────────────┘
```
**구현:**
1. 0_old의 RouteDesignStage.tsx 참고
2. 좌측: 매개변수 입력 (알고리즘, 등급, 곡선반경, 포장)
3. 우측: 3D Canvas
- 지표면 모델 렌더링 (TerrainModelViewer)
- 사용자가 클릭하여 경로점 추가
- 계산된 경로 선 표시
#### 2.4.5 B06_wf3_ProfileCross 재구현 계획
**사용자 흐름:**
```
[좌측 메뉴] [우측 메인 영역]
┌────────────────┐ ┌──────────────────────┐
│ WF3 종횡단 │ │ 1. 종단면 2D 그래프 │
│ ───────────── │ ──────→ │ (거리 vs 고도) │
│ 기준점 간격: │ │ │
│ [50] m │ │ [▼] 기준점 선택 │
│ │ │ │
│ 횡단면 너비: │ │ 2. 횡단면 2D 그래프 │
│ [20] m │ │ (오프셋 vs 고도) │
│ │ │ │
│ 샘플 간격: │ │ [▼] 다음 단계 진행 │
│ [1] m │ └──────────────────────┘
│ │
│ 연직 과장: │
│ [1.0] x │
│ │
│ 엔드포인트 │
│ 포함: │
│ ☐ 포함 │
│ │
│ [생성] │
└────────────────┘
```
**구현:**
1. 0_old의 LongitudinalProfile.tsx, CrossSectionGrid.tsx 참고
2. 좌측: 종횡단 매개변수 입력
3. 우측:
- 탭 1: 종단면 2D 그래프 (LongitudinalProfile.tsx)
- 탭 2: 횡단면 2D 그래프 (CrossSectionGrid.tsx)
- 기준점 선택 → 해당 종/횡단면 표시
#### 2.4.6 공통 레이아웃 템플릿
모든 WF(B04~B06)이 공유하는 레이아웃:
- **헤더:** 숨김/표시 토글, 프로젝트명, WF 단계
- **좌측 메뉴:** 매개변수 입력 (각 WF별로 상이)
- **우측 메인:** 3D 뷰어 또는 2D 그래프 (각 WF별로 상이)
- **스타일:** Wiza 테마 준수 (색상, 타입, 간격)
---
## 3. 데이터베이스 변경사항
### 3.1 projects 테이블 상태 값 확장
- 기존: `NEW`, `FILE_UPLOADED`, `WF1_ANALYZING`, `WF1_COMPLETE`, ...
- 추가 사항: 상태 변경 타이밍
- `FILE_UPLOADED` → `WF1_ANALYZING` (분석 시작 시)
- `WF1_ANALYZING` → `WF1_COMPLETE` (분석 완료 시)
- 각 단계 완료 후 이메일 발송 (기존)
### 3.2 업로드 세션 저장소 (클라이언트 localStorage 사용)
- DB 변경 없음 (localStorage에 JSON 저장)
- **키 이름 (§2.1.2.2와 동일 — 단일 키로 통일):** `b03_project_state_${projectId}`
- 값 형식은 §2.1.2.2 참조 (files, analysisStatus, analysisProgress, lastCheckedAt)
- 주의: localStorage는 브라우저별 격리(backend.md §5.1)이므로 **표시 복구 용도로만** 사용. 진실의 원천(source of truth)은 항상 서버(status API + DB `projects.status`)이며, 페이지 진입 시 서버 값으로 localStorage를 덮어쓴다.
---
## 4. API 엔드포인트 추가/수정
### 4.0 기존 API 현황 (⚠️ 코더는 신규 생성 전 반드시 확인 — 중복 생성 금지)
| 엔드포인트 | 메서드 | 상태 | 위치 |
|-----------|--------|------|------|
| `/api/projects/{id}/surface/analyze` | POST | ✅ 존재 (분석 실행 + DB 기록) | B04_wf1_Surface_Router.py |
| `/api/projects/{id}/surface/models` | GET | ✅ 존재 (모델 목록) | B04_wf1_Surface_Router.py |
| `/api/projects/{id}/surface/status` | GET | ✅ 존재 (확장 필요 — §2.1.2.3) | B04_wf1_Surface_Router.py |
| `/api/projects/{id}/upload-sessions`, `/chunks`, `/finalize`, `/upload-status/{sid}` | POST/GET | ✅ 존재 (청크 업로드) | B03_FileInput_Router.py |
### 4.1 신규 API (네이밍은 기존 `/surface/` 컨벤션 준수, `/wf1/` 접두사 사용 금지)
| 엔드포인트 | 메서드 | 설명 | 백엔드 파일 |
|-----------|--------|------|-----------|
| `/api/projects/{id}/surface/point-cloud` | GET | 포인트클라우드 샘플 (3D 뷰어용, 0_old `las-points-sample.json` 방식) | B04_wf1_Surface_Router.py |
| `/api/projects/{id}/surface/ground-stats` | GET | 필터별 지면 통계 | B04_wf1_Surface_Router.py |
| WF2/WF3 API | - | B05/B06 구현 시 동일 패턴으로 설계 (예: `/route/...`, `/sections/...`) | B05/B06 Router |
### 4.2 수정 API
| 엔드포인트 | 변경사항 |
|-----------|--------|
| `GET /api/projects/{id}/surface/status` | 응답에 `progress_percent`, `current_stage`, `message` 추가 + failed 상태 판별 (§2.1.2.3) |
| 대시보드 프로젝트 목록 API | 응답에 각 프로젝트의 `status` 필드 포함 여부 확인 후 필요 시 추가 (projects 테이블에 status 열 존재함) |
---
## 5. 프론트엔드 컴포넌트 신규 작성
### 5.1 WorkflowLayout 템플릿
- **파일:** `ui_template/ui_template_workflow_layout.ts`
- **파일:** `ui_template/ui_template_workflow_layout.css`
- **함수 시그니처:**
```typescript
export function createWorkflowLayout(options: WorkflowLayoutOptions): HTMLElement
```
### 5.2 PointCloudViewer 컴포넌트 (0_old 기반 개조)
- **파일:** `B04_wf1_Surface_UI_Viewer.ts` (700줄 제한 대비 별도 파일 — §2.4.2 제약 3)
- **의존성 (확인 완료):**
- `three@^0.185.0` — ✅ 현재 package.json에 설치됨 (0_old와 동일 버전)
- `maplibre-gl@^5.24.0` — ✅ 설치됨
- `geotiff` — ❌ 미설치 (0_old에는 있음). TIF 래스터를 프론트에서 직접 파싱할 경우에만 추가 필요. 백엔드에서 PNG 프리뷰로 변환해 전달하면 불필요 — **백엔드 변환 방식 권장** (0_old도 `preview.png` 생성 방식 사용)
- `lucide-react` — ❌ 사용 불가 (React 전용). 인라인 SVG/유니코드로 대체 (§2.4.2 제약 1)
- **주의:** React 훅 → vanilla TS 변환 필수 (§2.4.2 제약 1), 테마 변수 사용, 디자인 가이드 준수
### 5.3 모델 카드 프리뷰 컴포넌트
- **파일:** `ui_template/ui_template_elements.ts` 에 추가
- **함수:** `createSurfaceModelCard(model: SurfaceModelSummary, isSelected: boolean, onSelect: () => void)`
---
## 6. 구현 순서 및 의존성
### Phase 별 구현 계획
**Phase 1: 공통 인프라 (1~2일)**
- B03_FileInput_UI_Page.ts: 로딩 오버레이 제거 → 토스트 + localStorage로 전환
- B03_FileInput_Api_Fetch.ts: `getWF1AnalysisProgress()` API 추가
- localStorage 저장/복구 로직 구현
- 백엔드: 분석 진행률 파일(`.wf1_progress`) 작성 로직
**Phase 2: 대시보드 연동 (1일)**
- 🐞 워크플로우 버튼 클릭 시 `CURRENT_PROJECT_ID_KEY` 저장 버그 수정 (§2.2.2 — 최우선, 소규모)
- `_stage_from_status()`: ANALYZING/COMPLETE 구분 로직 (§2.2.1)
- 분석 진행 중 프로젝트 존재 시 목록 재조회 폴링 (5초 주기)
**Phase 3: WorkflowLayout 템플릿 (1~2일)**
- `ui_template/ui_template_workflow_layout.ts/css` 새로 작성
- 헤더 토글, 메뉴 오버레이 토글 기능
- Wiza 테마 색상/타이포그래피 준수
**Phase 4: B04_wf1_Surface 이식 (2~3일)**
- 0_old/PointCloudViewer.tsx → B04 컴포넌트 이식 (테마 적용)
- 0_old/TerrainModelViewer.tsx 이식
- B04_wf1_Surface_UI_Page.ts: 새 레이아웃 + 탭 구조
- API: `/wf1/analysis`, `/wf1/point-cloud`, `/wf1/ground-stats`, `/wf1/progress`
- 백엔드: 기존 분석 로직 + 진행률 추적
**Phase 5: B05_wf2_Route 이식 (2~3일)**
- 0_old/RouteDesignStage.tsx 이식
- B05_wf2_Route_UI_Page.ts: 새 레이아웃 + 3D Canvas
- API: `/wf2/surface-bounds`, `/wf2/calculate-route`, `/wf2/progress`
- 백엔드: 경로 계산 로직 (Dijkstra, Ridge-Valley)
**Phase 6: B06_wf3_ProfileCross 이식 (2~3일)**
- 0_old/LongitudinalProfile.tsx, CrossSectionGrid.tsx 이식
- B06_wf3_ProfileCross_UI_Page.ts: 새 레이아웃 + 2D 그래프 탭
- API: `/wf3/section-generation`, `/wf3/progress`
- 백엔드: 종횡단 생성 로직
**Phase 7: WF4~WF6 준비 (선택사항, 현재는 미구현)**
- 레이아웃 템플릿만 적용
- 콘텐츠는 향후 구현
---
## 7. 테스트 및 검증 계획
### 7.1 B03 파일 업로드
- [ ] 큰 파일 업로드 시 로딩 오버레이 없음 확인
- [ ] 업로드 중 다른 페이지 이동 가능 확인
- [ ] 페이지 리프레시 후 업로드 상태 복구 확인
- [ ] 분석 진행률 업데이트 확인
### 7.2 대시보드 워크플로우
- [ ] 분석 완료 후 WF1 버튼 활성화 확인
- [ ] 이메일 도착 후 대시보드 상태 갱신 확인
- [ ] 워크플로우 상태 폴링 작동 확인
- [ ] **기존 프로젝트의 B03 버튼 클릭 → "프로젝트가 선택되지 않았습니다" 에러 없이 진입 확인 (§2.2.2)**
- [ ] localStorage 비운 상태(시크릿 창)에서 워크플로우 버튼 진입 확인
- [ ] 프로젝트 2개 이상일 때 각 행의 버튼이 해당 프로젝트로 진입하는지 확인 (ID 교차 오염 없음)
- [ ] `WF1_ANALYZING` 상태에서 B04 버튼이 열리지 않는지 확인 (§2.2.1 토큰 매칭 수정)
### 7.3 WF1 레이아웃
- [ ] 헤더 숨김/표시 토글 작동 확인
- [ ] 메뉴 패널 오버레이 슬라이드 작동 확인
- [ ] 3D 포인트클라우드 렌더링 확인 (성능)
- [ ] 모델 카드 선택/상세 뷰 작동 확인
- [ ] 진행률 폴링 업데이트 확인
- [ ] input_file_id 자동 선택 확인 (수동 입력 제거 — §2.4.3)
- [ ] 분석 실패 시 failed 상태 표시 확인 (§2.1.2.3 status API 확장)
### 7.3.1 WF2 경로 설계 (Phase 5)
- [ ] 지표면 3D 뷰 위 경로점(BP/EP/CP/AP/FP) 클릭 추가 확인
- [ ] Dijkstra / Ridge-Valley 알고리즘 선택 및 경로 계산 확인
- [ ] 임도 등급·곡선반경·경사 한계 매개변수 반영 확인
### 7.3.2 WF3 종횡단 (Phase 6)
- [ ] 종단면 그래프 렌더링 + 기준점 선택 연동 확인
- [ ] 횡단면 그리드 렌더링 확인
- [ ] 기준점 간격/횡단 너비/연직 과장 매개변수 반영 확인
### 7.4 반응형 및 성능
- [ ] 다양한 브라우저 윈도우 크기에서 레이아웃 정렬 확인
- [ ] 포인트클라우드 대용량 렌더링 성능 (프레임률, 메모리)
- [ ] 모바일 환경에서 오버레이 레이아웃 작동 확인 (선택사항)
---
## 8. 위험 요소 및 제약사항
### 8.1 위험 요소
- **포인트클라우드 렌더링 성능:** 수억 개 포인트 샘플링/렌더링 시 프레임률 저하
- 대책: 밀도 조절 기능, 초기 샘플링 제한
- **대역폭:** 포인트클라우드 데이터 전송 시간
- 대책: 압축, 샘플링, 청크 다운로드
- **폴링 주기:** 분석 진행률 폴링이 너무 빈번하면 서버 부하 증가
- 대책: 5초 주기 유지
### 8.2 제약사항
- **디자인 가이드 준수:** `.agent/design.md`에 정의된 색상, 타이포그래피, 간격 엄격히 준수
- **레이아웃 재사용:** WF1~WF6이 동일 템플릿 사용하므로 템플릿 설계 철저히
- **백엔드 의존성:** 포인트클라우드 데이터, 진행률 API를 백엔드에서 제공해야 함
---
## 9. 주요 고려사항
### 9.1 대역폭 & 성능
- **포인트클라우드 샘플링:** 0_old는 최대 1000만 개 포인트 샘플링 후 JSON으로 전송
- 현재 프로젝트에서도 동일 방식 적용
- 필요시 바이너리 포맷(protobuf) 검토
- **3D 렌더링:** THREE.js 사용 (0_old와 동일)
- 성능 최적화: LOD(Level of Detail), 프러스텀 컬링 필요
- 모바일: 포인트 수 감소, 해상도 낮춤
### 9.2 브라우저 호환성
- Chrome/Edge/Firefox 최신 버전
- 모바일 사파리: 제한적 지원 (대역폭, 성능)
- WebGL 지원 필수
### 9.3 0_old 코드 이식 시 주의사항
- **좌표계:** CRS(EPSG:5178 등) 변환 로직 유지
- **단위:** 미터 기준 (높이, 거리, 반경 등)
- **파일 포맷:** JSON 저장 방식 통일 (MariaDB와 JSON 호환)
### 9.4 반응형 레이아웃
- 좌우측 너비 동적 조정
- 좌측 메뉴 토글 시 우측 캔버스 자동 확장
- 모바일: 전체 화면 사용 (좌측 메뉴는 오버레이만 표시)
---
## 10. 저장소 구조 정정 (코드베이스 검증)
### 10.0 계획서 §10.3 저장소 구조는 **0_old 기반이며, 현재 프로젝트와 다름**
**0_old 구조** (analyzer.py line 19):
```
storage/projects/{projectId}/
├── raw/ → 원본 파일
└── processed/ → 변환·분석 결과
```
**현재 프로젝트 실제 구조** (config_system.py line 240, 260, 247-256):
```
storage/{company_slug}/{user_slug}/{projectId}/
├── B03_FileInput/ → 원본 입력 (LAS/TIF/PRJ/TFW)
├── B04_wf1_Surface/ → WF1 변환·분석 결과
├── B05_wf2_Route/ → WF2 경로 설계 결과
├── B06_wf3_ProfileCross/ → WF3 종횡단 결과
├── B07_wf4_DesignDetail/ → WF4 구조물
├── B08_wf5_Quantity/ → WF5 수량
└── B09_wf6_Estimation/ → WF6 최종 산출물
```
**수정 대상 (코더는 아래 구조 사용):**
- 계획서 §4 "신규 API" 설명의 저장 경로 모두 현재 구조로 읽고 사용
- `storage/{projectId}/...` 축약 표기는 **사용 금지** — 전체 경로는 `storage/{company}/{user}/{projectId}/[B0N_]/...`
- 각 WF의 결과는 각각의 워크플로우 폴더 아래에만 저장 (§3 CLAUDE.md "단계별 루트 강제")
- 예: WF1 분석 결과 → `B04_wf1_Surface/` 아래만 (프로젝트 루트의 `result_*.json` 같은 축약 방식 금지)
---
## 11. 0_old 프로그램 구조 참고
### 10.1 프론트엔드 컴포넌트
```
0_old/frontend/src/
├── PointCloudViewer.tsx → B04 포인트클라우드 뷰어 참고
├── TerrainModelViewer.tsx → B04/B05 지표면 렌더링 참고
├── RouteDesignStage.tsx → B05 경로 설계 참고
├── LongitudinalProfile.tsx → B06 종단면 2D 그래프 참고
├── CrossSectionGrid.tsx → B06 횡단면 2D 그래프 참고
├── sectionTypes.ts → B06 데이터 타입 참고
├── WorkflowBar.tsx → 워크플로우 상태 표시 참고
├── pointCloudUtils.ts → 포인트 데이터 처리 유틸
└── MapViewerFrame → 줌/팬닝 뷰어 프레임 참고
```
### 10.2 백엔드 분석 엔진
```
0_old/backend/app/
├── analyzer.py → 분석 오케스트레이터
└── classifier.py → 포인트 분류
0_old/utils/
├── filter_grid_min_z.py → Grid Min-Z 필터
├── filter_csf.py → CSF(가상 천) 필터
├── filter_pmf.py → PMF(형태학) 필터
├── filter_ransac.py → RANSAC 필터
├── terrain_model_converter.py → TIN/DTM/NURBS/Implicit/Meshfree 생성
├── section_generator.py → 종횡단 생성
├── route_solver.py → Dijkstra 경로 계산
└── route_solver_ridgevalley.py → Ridge-Valley 경로 계산
```
### 10.3 저장소 구조
```
storage/projects/{projectId}/
├── raw/ → 원본 LAS/TIF/PRJ/TFW 파일
└── processed/
├── analysis.json → 메타데이터 (bounds, CRS 등)
├── las-points-sample.json → 샘플링된 포인트 (3D 뷰어용)
├── ground-stats.json → 필터별 지면 통계
├── tin.json → TIN 모델 (삼각형 메시)
├── dtm.json → DTM 모델 (격자)
├── nurbs.json → NURBS 모델
├── implicit.json → Implicit 모델
├── meshfree.json → Meshfree 모델
├── route.json → 계산된 경로 (경로점, 곡선 등)
└── sections.json → 종횡단 데이터 (LongitudinalSection[], CrossSection[])
```
---
## 11. 질의응답 정리
**사용자 피드백 적용:**
1.**진행률 추적:** localStorage + 폴링 으로 페이지 이탈 후에도 복구 가능
2.**데이터 구조:** LAS → JSON 변환 후 저장, 프론트는 저장된 데이터만 조회
3.**모바일 대응:** 전체 화면 사용으로 자동 반응형
4.**THREE.js:** 프로젝트에 설치됨, 0_old와 동일 방식 사용
5.**WF4~WF6:** 현재는 기능 없음 (향후 구현)
6.**0_old 참고:** 콘텐츠/렌더링은 0_old, 레이아웃/테마는 현재 프로젝트 기준
---
**계획서 최종 검토일:** 2026-07-10
**버전:** 2.0 (0_old 분석 기반)
**상태:** 완료 (코딩 팀 진행 대기)
+66
View File
@@ -0,0 +1,66 @@
# WF1~WF3 레이아웃 및 파일 업로드 개선 검증 보고서
**작성일:** 2026-07-10
**상태:** 전체 완료 검증
**대상 계획서:** [.agent/plan_wf1_and_upload_improvements.md](file:///D:/02_Software_Prog/임도설계 및 견적자동화 프로그램 개발/.agent/plan_wf1_and_upload_improvements.md)
---
## 1. 종합 검증 요약
계획서에 명시된 **Phase 1부터 Phase 6까지의 모든 단계**가 완전히 구현되었음을 확인했습니다.
이전 검증에서 통과된 파일 업로드 비동기화 및 대시보드 워크플로우 활성화 상태/컨텍스트 버그 수정(Phase 1, 2)에 이어, 공통 레이아웃 템플릿(Phase 3)과 각 워크플로우 페이지들의 리팩토링 및 0_old 기능 이식(Phase 4, 5, 6)이 순수 바닐라 TS 및 디자인 가이드를 준수하여 성공적으로 완료되었습니다.
---
## 2. 세부 검증 항목 및 결과 (기존)
### 2.1 B03_FileInput 파일 업로드 비동기 처리 (Phase 1)
* **[합격] UI 블로킹 제거 및 백그라운드 진행**:
* [B03_FileInput_UI_Page.ts](file:///D:/02_Software_Prog/임도설계 및 견적자동화 프로그램 개발/B03_FileInput/B03_FileInput_UI_Page.ts) 파일에서 `showLoadingOverlay()``hideLoadingOverlay()` 호출부가 완벽하게 제거되었습니다.
* 파일 업로드 후 백그라운드 분석 진행 중에도 사용자는 자유롭게 다른 화면으로 이동할 수 있습니다.
* **[합격] 분석 진행 상태 API 확장**:
* [B04_wf1_Surface_Router.py](file:///D:/02_Software_Prog/임도설계 및 견적자동화 프로그램 개발/B04_wf1_Surface/B04_wf1_Surface_Router.py)의 `get_wf1_analysis_status()` API가 확장되어 프로젝트의 실시간 status(`WF1_ANALYZING`, `WF1_COMPLETE`, `WF1_FAILED`)에 따른 진행률(`progress_percent`), 진행 단계(`current_stage`), 상태 메시지(`message`)를 반환하도록 기능이 구현되었습니다.
* [B03_FileInput_Api_Fetch.ts](file:///D:/02_Software_Prog/임도설계 및 견적자동화 프로그램 개발/B03_FileInput/B03_FileInput_Api_Fetch.ts)의 `WF1AnalysisStatus` 인터페이스에 추가된 응답 규격이 바르게 정의되었습니다.
### 2.2 대시보드 워크플로우 활성화 및 프로젝트 컨텍스트 유지 (Phase 2, 🐞 버그 수정)
* **[합격] 프로젝트 ID 누락으로 인한 진입 오류 해결 (§2.2.2)**:
* [B01_Dashboard_UI_Page.ts](file:///D:/02_Software_Prog/임도설계 및 견적자동화 프로그램 개발/B01_Dashboard/B01_Dashboard_UI_Page.ts)의 `workflow()` 함수가 `ProjectItem` 객체를 파라미터로 받도록 개선되었습니다.
* 각 단계 이동 버튼의 클릭 이벤트에 `localStorage.setItem(CURRENT_PROJECT_ID_KEY, project.id)`가 추가되어 기존 프로젝트 클릭 후 파일 입력 진입 시 발생하던 "현재 프로젝트가 선택되지 않았습니다" 에러가 완전히 수정되었습니다.
* **[합격] 상태값 매칭 오류 수정 및 스테이지 통제 (§2.2.1)**:
* [B01_Dashboard_Repository.py](file:///D:/02_Software_Prog/임도설계 및 견적자동화 프로그램 개발/B01_Dashboard/B01_Dashboard_Repository.py)의 `_stage_from_status()` 함수에서 분석 중(`WF1_ANALYZING`)이거나 실패(`WF1_FAILED`) 시에는 다음 단계로 갈 수 없도록 명시적으로 `stage 1`을 반환하게 통제되었습니다.
* `WF1_COMPLETE`, `WF2_COMPLETE` 와 같은 완결 접미사를 기준으로 단계를 정확히 판별하도록 개선되었습니다.
---
## 3. 신규 검증 항목 및 결과 (Phase 3 ~ Phase 6)
### 3.1 공통 워크플로우 레이아웃 템플릿 구현 (Phase 3)
* **[합격] 바닐라 TS 기반 레이아웃 템플릿**:
* [ui_template_workflow_layout.ts](file:///D:/02_Software_Prog/임도설계 및 견적자동화 프로그램 개발/ui_template/ui_template_workflow_layout.ts)에 `createWorkflowLayout` 함수가 성공적으로 추가되었습니다.
* ☰ 버튼 토글 기능, 상단 헤더 및 진행률 스텝바 렌더링, 오버레이 형식의 좌측 패널 슬라이드 기능이 바닐라 TS DOM 구조로 구현되었습니다.
* 메인 작업 영역 클릭 시 오버레이 메뉴가 자동으로 닫히는 UX 흐름이 완벽히 적용되었습니다.
### 3.2 B04_wf1_Surface 지표면 분석 페이지 이식 및 자동화 (Phase 4)
* **[합격] 3D 포인트클라우드 WebGL 뷰어 이식**:
* [B04_wf1_Surface_UI_Viewer.ts](file:///D:/02_Software_Prog/임도설계 및 견적자동화 프로그램 개발/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts)가 신규 생성되어 THREE.js OrbitControls를 활용한 3D 가속 가상 카메라 뷰 환경이 완벽히 구성되었습니다.
* React 훅과 의존성 없이 바닐라 TS 생명주기(`dispose`, `render`)를 갖는 컴포넌트로 개조되었습니다.
* **[합격] 입력 파일 자동 로드 및 UI 연동**:
* [B04_wf1_Surface_UI_Page.ts](file:///D:/02_Software_Prog/임도설계 및 견적자동화 프로그램 개발/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts)에서 기존의 숫자 직접 입력(`input_file_id`) 필드를 대체하여, 프로젝트에 연동된 입력 파일 목록을 콤보박스(`inputSelect`)로 자동 렌더링하고 첫 번째 파일을 자동 선택하도록 편의성을 높였습니다.
* 지면 필터 통계 목록과 분석 완료된 지표면 결과 모델들을 카드 형식으로 표출하며 3D 뷰어와 유기적으로 호환됩니다.
* **[합격] 코드 라인 수 제한 및 스타일 시트 분리**:
* UI 로직은 536줄, 3D 뷰어는 122줄로 분할 구현되어 단일 파일 700줄 제한을 준수했습니다.
### 3.3 B05_wf2_Route 및 B06_wf3_ProfileCross 리팩토링 (Phase 5, 6)
* **[합격] B05 경로 설계 페이지 개선**:
* [B05_wf2_Route_UI_Page.ts](file:///D:/02_Software_Prog/임도설계 및 견적자동화 프로그램 개발/B05_wf2_Route/B05_wf2_Route_UI_Page.ts)에 `createWorkflowLayout` 템플릿이 적용되었습니다.
* 시점(BP), 종점(EP) 및 동적 추가 가능한 중간점(CP) 폼 세트가 정상 기능하며, 설계 조건 조절 및 API 요청/승인 비즈니스 로직이 바르게 바인딩되었습니다.
* **[합격] B06 종횡단 생성 페이지 개선**:
* [B06_wf3_ProfileCross_UI_Page.ts](file:///D:/02_Software_Prog/임도설계 및 견적자동화 프로그램 개발/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts)에 공통 레이아웃이 연동되어 대형 공간에서 2D 뷰 기반 종/횡단 작업을 수행할 수 있도록 좌측 매개변수 제어창과 우측 결과 보드가 통합 설계되었습니다.
---
## 4. 최종 제언 및 사후 검토
* 코드 품질 검증 결과 전체적인 컴포넌트의 모듈화가 성공적으로 안착되었습니다.
* 프로젝트 전반에서 `ui_template_locale.ts`를 사용한 한글/영문 지원 및 테마 변수의 체계적인 연동이 모범적으로 준수되었습니다.
* 추후 3D 뷰어에서의 드로잉 인터랙션 및 2D 그래프 렌더링 세부 작업 시에도 설계된 `createWorkflowLayout` 인터페이스를 적극 활용하시기 바랍니다.
+9 -7
View File
@@ -18,18 +18,20 @@ def _role(value: str | None) -> str:
def _stage_from_status(status: str | None) -> tuple[int, int]: def _stage_from_status(status: str | None) -> tuple[int, int]:
value = status or "NEW"
if value in {"WF1_ANALYZING", "WF1_FAILED"}:
return 1, round(1 / 7 * 100)
order = [ order = [
("FILE_UPLOADED", 1), ("FILE_UPLOADED", 1),
("WF1", 2), ("WF1_COMPLETE", 2),
("WF2", 3), ("WF2_COMPLETE", 3),
("WF3", 4), ("WF3_COMPLETE", 4),
("WF4", 5), ("WF4_COMPLETE", 5),
("WF5", 6), ("WF5_COMPLETE", 6),
("WF6", 7), ("WF6_COMPLETE", 7),
("DONE", 7), ("DONE", 7),
("CONFIRMED", 7), ("CONFIRMED", 7),
] ]
value = status or "NEW"
stage = 0 stage = 0
for token, idx in order: for token, idx in order:
if token in value: if token in value:
+4 -4
View File
@@ -1,6 +1,6 @@
import type { DashboardUser, ProjectItem, Member } from "./B01_Dashboard_Api_Fetch"; import type { DashboardUser, ProjectItem, Member } from "./B01_Dashboard_Api_Fetch";
export function canEditProject(user: DashboardUser, project: ProjectItem): boolean { export function canEditProject(user: DashboardUser, _project: ProjectItem): boolean {
if (user.role === "SYSTEM_ADMIN") return true; if (user.role === "SYSTEM_ADMIN") return true;
if (user.role === "ADMIN") return user.company_id !== null; if (user.role === "ADMIN") return user.company_id !== null;
return false; // USER는 수정 불가 return false; // USER는 수정 불가
@@ -16,12 +16,12 @@ export function canAddUser(user: DashboardUser): boolean {
return user.role === "SYSTEM_ADMIN" || user.role === "ADMIN"; return user.role === "SYSTEM_ADMIN" || user.role === "ADMIN";
} }
export function canChangeRole(user: DashboardUser, targetUser: Member | DashboardUser): boolean { export function canChangeRole(user: DashboardUser, _targetUser: Member | DashboardUser): boolean {
// 역할 변경은 오직 SYSTEM_ADMIN만 가능 // 역할 변경은 오직 SYSTEM_ADMIN만 가능
return user.role === "SYSTEM_ADMIN"; return user.role === "SYSTEM_ADMIN";
} }
export function canDeleteUser(user: DashboardUser, targetUser: Member | DashboardUser): boolean { export function canDeleteUser(user: DashboardUser, _targetUser: Member | DashboardUser): boolean {
if (user.role === "SYSTEM_ADMIN") return true; if (user.role === "SYSTEM_ADMIN") return true;
// ADMIN은 본인 회사의 멤버만 삭제 가능 // ADMIN은 본인 회사의 멤버만 삭제 가능
return user.role === "ADMIN" && user.company_id !== null; return user.role === "ADMIN" && user.company_id !== null;
@@ -29,7 +29,7 @@ export function canDeleteUser(user: DashboardUser, targetUser: Member | Dashboar
export function canManageAutomation( export function canManageAutomation(
user: DashboardUser, user: DashboardUser,
projectOrAutomation: ProjectItem | { company_id?: number | null }, _projectOrAutomation: ProjectItem | { company_id?: number | null },
): boolean { ): boolean {
return user.role === "SYSTEM_ADMIN" || (user.role === "ADMIN" && user.company_id !== null); return user.role === "SYSTEM_ADMIN" || (user.role === "ADMIN" && user.company_id !== null);
} }
-1
View File
@@ -26,7 +26,6 @@ import {
type ProjectItem, type ProjectItem,
type Member, type Member,
type AutomationItem, type AutomationItem,
type CompanyInfo,
} from "./B01_Dashboard_Api_Fetch"; } from "./B01_Dashboard_Api_Fetch";
function L(key: keyof typeof ui_locales): string { function L(key: keyof typeof ui_locales): string {
+8 -4
View File
@@ -1,4 +1,4 @@
import { ROUTES } from "@config/config_frontend"; import { CURRENT_PROJECT_ID_KEY, ROUTES, type RoutePath } from "@config/config_frontend";
import { isBlank } from "@util/common_util_validate"; import { isBlank } from "@util/common_util_validate";
import { navigateTo } from "../A00_Common/router"; import { navigateTo } from "../A00_Common/router";
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
@@ -359,7 +359,7 @@ function projectTable(projects: ProjectItem[], currentUser: DashboardUser): HTML
text(project.name), text(project.name),
text(project.region), text(project.region),
text(`${project.progress_percent}%`), text(`${project.progress_percent}%`),
workflow(project.workflow_stage), workflow(project),
text(formatDate(project.updated_at)), text(formatDate(project.updated_at)),
actCell, actCell,
]; ];
@@ -367,7 +367,8 @@ function projectTable(projects: ProjectItem[], currentUser: DashboardUser): HTML
); );
} }
function workflow(activeStage: number): HTMLElement { function workflow(project: ProjectItem): HTMLElement {
const activeStage = project.workflow_stage;
const routes: RoutePath[] = [ const routes: RoutePath[] = [
ROUTES.B03_FILE_INPUT, ROUTES.B03_FILE_INPUT,
ROUTES.B04_WF1_SURFACE, ROUTES.B04_WF1_SURFACE,
@@ -388,7 +389,10 @@ function workflow(activeStage: number): HTMLElement {
button.disabled = !enabled; button.disabled = !enabled;
if (enabled) { if (enabled) {
button.classList.add("is-enabled"); button.classList.add("is-enabled");
button.addEventListener("click", () => navigateTo(route)); button.addEventListener("click", () => {
localStorage.setItem(CURRENT_PROJECT_ID_KEY, project.id);
navigateTo(route);
});
} }
box.append(button); box.append(button);
}); });
@@ -10,20 +10,11 @@ from uuid import uuid4
import aiomysql import aiomysql
from common_util.common_util_json import atomic_write_json from common_util.common_util_json import atomic_write_json
from common_util.common_util_storage import PROJECT_STORAGE_LAYOUT_V2
from common_util.common_util_workflow import load_project_workflow from common_util.common_util_workflow import load_project_workflow
from config.config_db import get_db_pool from config.config_db import get_db_pool
from config.config_system import STORAGE_BASE_DIR from config.config_system import STORAGE_BASE_DIR
PROJECT_STAGE_SUBDIRS = (
("B03_FileInput", "input"),
("B04_wf1_Surface", "processed"),
("B05_wf2_Route", "route"),
("B06_wf3_ProfileCross", "profiles"),
("B07_wf4_DesignDetail", "structures"),
("B08_wf5_Quantity", "quantities"),
("B09_wf6_Estimation", "v1"),
)
def _build_project_storage(company_id: int, user_id: int, project_id: str) -> tuple[str, Path]: def _build_project_storage(company_id: int, user_id: int, project_id: str) -> tuple[str, Path]:
relative_path = f"storage/{company_id}/{user_id}/{project_id}" relative_path = f"storage/{company_id}/{user_id}/{project_id}"
@@ -35,16 +26,16 @@ def _build_project_storage(company_id: int, user_id: int, project_id: str) -> tu
def _initialize_project_storage(project_root: Path, project_id: str) -> None: def _initialize_project_storage(project_root: Path, project_id: str) -> None:
for stage, subdir in PROJECT_STAGE_SUBDIRS: for stage, subdir in PROJECT_STORAGE_LAYOUT_V2:
(project_root / stage / subdir).mkdir(parents=True, exist_ok=True) (project_root / stage / subdir).mkdir(parents=True, exist_ok=True)
atomic_write_json(project_root / "workflow.json", load_project_workflow(project_root)) atomic_write_json(project_root / "workflow.json", load_project_workflow(project_root))
atomic_write_json( atomic_write_json(
project_root / "project_manifest.json", project_root / "project_manifest.json",
{ {
"project_id": project_id, "project_id": project_id,
"storage_version": 1, "storage_version": 2,
"created_at": datetime.utcnow().isoformat(timespec="seconds"), "created_at": datetime.utcnow().isoformat(timespec="seconds"),
"stages": [f"{stage}/{subdir}" for stage, subdir in PROJECT_STAGE_SUBDIRS], "stages": [f"{stage}/{subdir}" for stage, subdir in PROJECT_STORAGE_LAYOUT_V2],
}, },
) )
+3
View File
@@ -145,6 +145,9 @@ export interface WF1AnalysisStatus {
project_id: string; project_id: string;
status: "pending" | "in_progress" | "completed" | "failed"; status: "pending" | "in_progress" | "completed" | "failed";
model_count: number; model_count: number;
progress_percent: number;
current_stage: string;
message: string;
error?: string; error?: string;
} }
+27 -3
View File
@@ -102,6 +102,20 @@ async def _get_project_notification_info(
return dict(row) if row else None return dict(row) if row else None
async def _update_project_status(project_id: UUID, status: str) -> None:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"""
UPDATE projects
SET status = %s, updated_at = NOW()
WHERE id = %s AND deleted_at IS NULL
""",
(status, str(project_id)),
)
await connection.commit()
async def _send_upload_complete_notification( async def _send_upload_complete_notification(
*, *,
project_id: UUID, project_id: UUID,
@@ -141,6 +155,7 @@ async def trigger_wf1_analysis_and_email(
pool = get_db_pool() pool = get_db_pool()
project_info: dict[str, Any] | None = None project_info: dict[str, Any] | None = None
try: try:
await _update_project_status(project_id, "WF1_ANALYZING")
async with pool.acquire() as connection: async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id) stored_path = await get_project_storage_relative_path(connection, project_id)
project_info = await _get_project_notification_info(connection, project_id) project_info = await _get_project_notification_info(connection, project_id)
@@ -192,13 +207,18 @@ async def trigger_wf1_analysis_and_email(
source_filters=source_filters, source_filters=source_filters,
) )
await connection.commit() await connection.commit()
await _update_project_status(project_id, "WF1_COMPLETE")
logger.info("WF1 분석 결과 DB 저장 완료: project_id=%s", project_id) logger.info("WF1 분석 결과 DB 저장 완료: project_id=%s", project_id)
except Exception as e: except Exception as e:
logger.exception("WF1 분석 결과 DB 저장 실패: %s", e) logger.exception("WF1 분석 결과 DB 저장 실패: %s", e)
await connection.rollback() await connection.rollback()
raise raise
logger.info("SEND_ANALYSIS_COMPLETION_EMAIL=%s, project_info=%s", SEND_ANALYSIS_COMPLETION_EMAIL, bool(project_info)) logger.info(
"SEND_ANALYSIS_COMPLETION_EMAIL=%s, project_info=%s",
SEND_ANALYSIS_COMPLETION_EMAIL,
bool(project_info),
)
if SEND_ANALYSIS_COMPLETION_EMAIL and project_info and project_info.get("user_email"): if SEND_ANALYSIS_COMPLETION_EMAIL and project_info and project_info.get("user_email"):
logger.info("WF1 완료 이메일 발송 시작: to=%s", project_info["user_email"]) logger.info("WF1 완료 이메일 발송 시작: to=%s", project_info["user_email"])
await send_analysis_completion_email( await send_analysis_completion_email(
@@ -209,11 +229,15 @@ async def trigger_wf1_analysis_and_email(
) )
logger.info("WF1 완료 이메일 발송 완료: project_id=%s", project_id) logger.info("WF1 완료 이메일 발송 완료: project_id=%s", project_id)
else: else:
logger.info("WF1 완료 이메일 발송 스킵: SEND_ANALYSIS_COMPLETION_EMAIL=%s, has_email=%s", logger.info(
SEND_ANALYSIS_COMPLETION_EMAIL, project_info and project_info.get("user_email") is not None) "WF1 완료 이메일 발송 스킵: SEND_ANALYSIS_COMPLETION_EMAIL=%s, has_email=%s",
SEND_ANALYSIS_COMPLETION_EMAIL,
project_info and project_info.get("user_email") is not None,
)
logger.info("WF1 백그라운드 분석 완료: project_id=%s", project_id) logger.info("WF1 백그라운드 분석 완료: project_id=%s", project_id)
except Exception as exc: except Exception as exc:
logger.exception("WF1 백그라운드 분석 실패: project_id=%s", project_id) logger.exception("WF1 백그라운드 분석 실패: project_id=%s", project_id)
await _update_project_status(project_id, "WF1_FAILED")
if project_info and project_info.get("user_email"): if project_info and project_info.get("user_email"):
await send_analysis_error_email( await send_analysis_error_email(
project_id=project_id, project_id=project_id,
+90
View File
@@ -0,0 +1,90 @@
export type B03AnalysisStatus = "pending" | "in_progress" | "completed" | "failed";
export interface B03StoredFileState {
slot: string;
fileName: string;
fileSize: number;
status: "completed" | "failed";
}
export interface B03ProjectState {
projectId: string;
uploadedAt: number;
files: B03StoredFileState[];
analysisStatus: B03AnalysisStatus;
analysisProgress: number;
lastCheckedAt: number;
}
function key(projectId: string): string {
return `b03_project_state_${projectId}`;
}
export function loadB03ProjectState(projectId: string): B03ProjectState | null {
const raw = localStorage.getItem(key(projectId));
if (!raw) return null;
try {
return JSON.parse(raw) as B03ProjectState;
} catch {
localStorage.removeItem(key(projectId));
return null;
}
}
export function saveB03UploadedFile(
projectId: string,
file: { slot: string; fileName: string; fileSize: number },
): B03ProjectState {
const current = loadB03ProjectState(projectId);
const nextFiles = (current?.files ?? []).filter((item) => item.slot !== file.slot);
nextFiles.push({ ...file, status: "completed" });
const next: B03ProjectState = {
projectId,
uploadedAt: current?.uploadedAt ?? Date.now(),
files: nextFiles,
analysisStatus: "pending",
analysisProgress: 0,
lastCheckedAt: Date.now(),
};
localStorage.setItem(key(projectId), JSON.stringify(next));
return next;
}
export function updateB03AnalysisState(
projectId: string,
status: B03AnalysisStatus,
progress: number,
): B03ProjectState {
const current = loadB03ProjectState(projectId);
const next: B03ProjectState = {
projectId,
uploadedAt: current?.uploadedAt ?? Date.now(),
files: current?.files ?? [],
analysisStatus: status,
analysisProgress: progress,
lastCheckedAt: Date.now(),
};
localStorage.setItem(key(projectId), JSON.stringify(next));
return next;
}
export function restoreB03ProjectState(options: {
projectId: string;
label: string;
container: HTMLElement;
poll: (projectId: string) => Promise<boolean>;
onComplete: () => void;
}): void {
const state = loadB03ProjectState(options.projectId);
if (!state) return;
options.container.replaceChildren();
const message = document.createElement("span");
message.textContent = `${options.label}: ${state.analysisProgress}%`;
options.container.append(message);
options.container.classList.add("is-visible");
if (state.analysisStatus === "in_progress" || state.analysisStatus === "pending") {
void options.poll(options.projectId).then((completed) => {
if (completed) options.onComplete();
});
}
}
+25 -27
View File
@@ -1,5 +1,3 @@
/* B03 파일 입력 페이지 - 가로형 드롭존 + 슬롯 그리드 + 청크 업로드 */
import { import {
CURRENT_PROJECT_ID_KEY, CURRENT_PROJECT_ID_KEY,
PROGRESS_UPDATE_INTERVAL_MS, PROGRESS_UPDATE_INTERVAL_MS,
@@ -9,15 +7,7 @@ import {
UPLOAD_MAX_MB, UPLOAD_MAX_MB,
} from "@config/config_frontend"; } from "@config/config_frontend";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { import { createButton, createTag, createWorkflowShell, showToast } from "@ui/ui_template_elements";
createButton,
createCard,
createTag,
createWorkflowShell,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { import {
checkWF1AnalysisStatus, checkWF1AnalysisStatus,
createUploadSession, createUploadSession,
@@ -27,6 +17,11 @@ import {
} from "./B03_FileInput_Api_Fetch"; } from "./B03_FileInput_Api_Fetch";
import { navigateTo } from "../A00_Common/router"; import { navigateTo } from "../A00_Common/router";
import { ROUTES } from "@config/config_frontend"; import { ROUTES } from "@config/config_frontend";
import {
restoreB03ProjectState,
saveB03UploadedFile,
updateB03AnalysisState,
} from "./B03_FileInput_State";
import "./B03_FileInput_UI_Style.css"; import "./B03_FileInput_UI_Style.css";
type FileSlot = "las_laz" | "prj" | "tfw" | "tif" | "dxf"; type FileSlot = "las_laz" | "prj" | "tfw" | "tif" | "dxf";
@@ -196,8 +191,7 @@ export function renderB03FileInput(root: HTMLElement): void {
}); });
shell.root.classList.add("b03-file"); shell.root.classList.add("b03-file");
// 레이아웃 전면 리팩토링: 좌측/우측 패널 구분 없이 하나의 와이드 컴포넌트로 결합 shell.leftPanel.remove();
shell.leftPanel.remove(); // 기존 좁은 세로 영역 제거
const contentContainer = document.createElement("div"); const contentContainer = document.createElement("div");
contentContainer.className = "b03-file__main-layout"; contentContainer.className = "b03-file__main-layout";
@@ -248,7 +242,6 @@ export function renderB03FileInput(root: HTMLElement): void {
const cssState = stateName === "failed" ? "error" : stateName; const cssState = stateName === "failed" ? "error" : stateName;
card.classList.add(`b03-file__card--${cssState}`); card.classList.add(`b03-file__card--${cssState}`);
// 배지 상태 연동 업데이트
const badgeContainer = card.querySelector<HTMLDivElement>(".b03-file__card-badge-container"); const badgeContainer = card.querySelector<HTMLDivElement>(".b03-file__card-badge-container");
if (badgeContainer) { if (badgeContainer) {
badgeContainer.replaceChildren(); badgeContainer.replaceChildren();
@@ -564,6 +557,11 @@ export function renderB03FileInput(root: HTMLElement): void {
const response = await finalizeUploadSession(projectId, session, totalChunks); const response = await finalizeUploadSession(projectId, session, totalChunks);
localStorage.removeItem(storageKey); localStorage.removeItem(storageKey);
saveB03UploadedFile(projectId, {
slot: state.slot,
fileName: file.name,
fileSize: file.size,
});
state.progressBytes = file.size; state.progressBytes = file.size;
state.speedMbs = state.speedMbs =
file.size / 1024 / 1024 / Math.max(0.001, (performance.now() - startedAt) / 1000); file.size / 1024 / 1024 / Math.max(0.001, (performance.now() - startedAt) / 1000);
@@ -577,13 +575,11 @@ export function renderB03FileInput(root: HTMLElement): void {
for (let attempt = 0; attempt < maxAttempts; attempt += 1) { for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try { try {
const status = await checkWF1AnalysisStatus(projectId); const status = await checkWF1AnalysisStatus(projectId);
updateB03AnalysisState(projectId, status.status, status.progress_percent);
if (status.status === "completed") { if (status.status === "completed") {
return true; return true;
} }
} catch { } catch {}
// 상태 조회 실패는 무시하고 계속 폴링
}
// 5초마다 폴링 (최대 30분)
await new Promise((resolve) => setTimeout(resolve, 5000)); await new Promise((resolve) => setTimeout(resolve, 5000));
} }
return false; return false;
@@ -599,24 +595,20 @@ export function renderB03FileInput(root: HTMLElement): void {
pageError.textContent = ""; pageError.textContent = "";
const uploaded: UploadedFileResult[] = []; const uploaded: UploadedFileResult[] = [];
showLoadingOverlay();
try { try {
for (const state of targetStates) { for (const state of targetStates) {
uploaded.push(...(await uploadOneFile(activeProjectId, state))); uploaded.push(...(await uploadOneFile(activeProjectId, state)));
} }
renderUploadResults(uploaded); renderUploadResults(uploaded);
showToast(L("B03_File_Upload_Success"), "success"); showToast(L("B03_File_Upload_Success"), "success");
hideLoadingOverlay();
// WF1 분석 상태 폴링 시작 showToast(L("B03_File_Analysis_InProgress"), "info");
showToast("WF1 분석 진행 중... 완료되면 자동으로 이동합니다.", "info");
const analysisComplete = await pollWF1Analysis(activeProjectId); const analysisComplete = await pollWF1Analysis(activeProjectId);
if (analysisComplete) { if (analysisComplete) {
// 분석 완료 시 B04 페이지로 이동
navigateTo(ROUTES.B04_WF1_SURFACE); navigateTo(ROUTES.B04_WF1_SURFACE);
} else { } else {
showToast("분석이 진행 중입니다. 잠시 후 새로고침해주세요.", "warning"); showToast(L("B03_File_Analysis_StillRunning"), "warning");
} }
} catch (error) { } catch (error) {
const failed = targetStates.find((state) => state.uploadStatus === "uploading"); const failed = targetStates.find((state) => state.uploadStatus === "uploading");
@@ -624,8 +616,6 @@ export function renderB03FileInput(root: HTMLElement): void {
if (failed) showErrorMessage(failed.slot, detail); if (failed) showErrorMessage(failed.slot, detail);
pageError.textContent = `${L("B03_File_Upload_Failed")} ${detail}`; pageError.textContent = `${L("B03_File_Upload_Failed")} ${detail}`;
showToast(L("B03_File_Upload_Failed"), "error"); showToast(L("B03_File_Upload_Failed"), "error");
} finally {
hideLoadingOverlay();
} }
} }
@@ -694,5 +684,13 @@ export function renderB03FileInput(root: HTMLElement): void {
for (const slot of slots.keys()) renderSlot(slot); for (const slot of slots.keys()) renderSlot(slot);
void registerB03ServiceWorker(); void registerB03ServiceWorker();
void detectPausedUploads(); void detectPausedUploads();
if (activeProjectId) {
restoreB03ProjectState({
projectId: activeProjectId,
label: L("B03_File_Restore_State"),
container: resumeBanner,
poll: pollWF1Analysis,
onComplete: () => navigateTo(ROUTES.B04_WF1_SURFACE),
});
}
} }
@@ -40,6 +40,47 @@ export interface SurfaceModelSummary {
created_at: string | null; created_at: string | null;
} }
export interface SurfaceInputFileSummary {
id: number;
file_type: string;
original_filename: string;
raw_file_path: string;
file_size_mb: number | null;
crs_epsg: number | null;
status: string | null;
created_at: string | null;
}
export interface SurfaceInputFileListResponse {
status: string;
project_id: string;
files: SurfaceInputFileSummary[];
}
export interface SurfacePointCloudSampleResponse {
status: string;
project_id: string;
point_count: number;
sampled_count: number;
bounds: Record<string, number>;
points: [number, number, number][];
}
export interface SurfaceGroundStatsResponse {
status: string;
project_id: string;
filters: Record<string, Record<string, unknown>>;
}
export interface SurfaceStatusResponse {
project_id: string;
status: "pending" | "in_progress" | "completed" | "failed";
model_count: number;
progress_percent: number;
current_stage: string;
message: string;
}
/** 지표면 모델 목록 응답 (SurfaceModelListResponse) */ /** 지표면 모델 목록 응답 (SurfaceModelListResponse) */
export interface SurfaceModelListResponse { export interface SurfaceModelListResponse {
status: string; status: string;
@@ -88,3 +129,36 @@ export async function listSurfaceModels(projectId: string): Promise<SurfaceModel
method: "GET", method: "GET",
}); });
} }
export async function listSurfaceInputFiles(
projectId: string,
): Promise<SurfaceInputFileListResponse> {
return requestJson<SurfaceInputFileListResponse>(`/projects/${projectId}/surface/input-files`, {
method: "GET",
});
}
export async function fetchSurfacePointCloud(
projectId: string,
): Promise<SurfacePointCloudSampleResponse> {
return requestJson<SurfacePointCloudSampleResponse>(
`/projects/${projectId}/surface/point-cloud`,
{
method: "GET",
},
);
}
export async function fetchSurfaceGroundStats(
projectId: string,
): Promise<SurfaceGroundStatsResponse> {
return requestJson<SurfaceGroundStatsResponse>(`/projects/${projectId}/surface/ground-stats`, {
method: "GET",
});
}
export async function fetchSurfaceStatus(projectId: string): Promise<SurfaceStatusResponse> {
return requestJson<SurfaceStatusResponse>(`/projects/${projectId}/surface/status`, {
method: "GET",
});
}
@@ -198,6 +198,38 @@ async def get_input_file(
} }
async def list_project_point_cloud_inputs(
connection: aiomysql.Connection, project_id: UUID
) -> list[dict[str, Any]]:
"""프로젝트의 LAS/LAZ 원본 입력 파일 목록을 최신순으로 조회한다."""
async with connection.cursor() as cursor:
await cursor.execute(
"""
SELECT id, file_type, original_filename, raw_file_path, file_size_mb,
crs_epsg, status, created_at
FROM input_files
WHERE project_id = %s AND file_type IN ('las', 'laz')
ORDER BY created_at DESC, id DESC
""",
(str(project_id),),
)
rows = await cursor.fetchall()
return [
{
"id": int(row[0]),
"file_type": row[1],
"original_filename": row[2],
"raw_file_path": row[3],
"file_size_mb": float(row[4]) if row[4] is not None else None,
"crs_epsg": row[5],
"status": row[6],
"created_at": row[7].isoformat() if row[7] else None,
}
for row in rows
]
async def list_surface_models( async def list_surface_models(
connection: aiomysql.Connection, project_id: UUID connection: aiomysql.Connection, project_id: UUID
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
+167 -4
View File
@@ -1,12 +1,14 @@
"""B04 지표면 분석 FastAPI 라우터.""" """B04 지표면 분석 FastAPI 라우터."""
import asyncio import asyncio
import json
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
import aiomysql import aiomysql
import numpy as np
from fastapi import APIRouter from fastapi import APIRouter
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -17,19 +19,39 @@ from B04_wf1_Surface.B04_wf1_Surface_Repository import (
create_surface_model, create_surface_model,
create_terrain_layer, create_terrain_layer,
get_input_file, get_input_file,
list_project_point_cloud_inputs,
list_surface_models, list_surface_models,
) )
from B04_wf1_Surface.B04_wf1_Surface_Schema import ( from B04_wf1_Surface.B04_wf1_Surface_Schema import (
SurfaceAnalyzeRequest, SurfaceAnalyzeRequest,
SurfaceAnalyzeResponse, SurfaceAnalyzeResponse,
SurfaceGroundStatsResponse,
SurfaceInputFileListResponse,
SurfaceInputFileSummary,
SurfaceModelListResponse, SurfaceModelListResponse,
SurfaceModelSummary, SurfaceModelSummary,
SurfacePointCloudSampleResponse,
) )
from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool from config.config_db import get_db_pool
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Analysis"]) router = APIRouter(prefix="/api/projects", tags=["B04 Surface Analysis"])
POINT_CLOUD_SAMPLE_LIMIT = 100_000
async def update_project_status(
connection: aiomysql.Connection, project_id: UUID, status: str
) -> None:
async with connection.cursor() as cursor:
await cursor.execute(
"""
UPDATE projects
SET status = %s, updated_at = NOW()
WHERE id = %s AND deleted_at IS NULL
""",
(status, str(project_id)),
)
async def save_surface_analysis_to_db( async def save_surface_analysis_to_db(
@@ -103,6 +125,8 @@ async def analyze_surface(
pool = get_db_pool() pool = get_db_pool()
try: try:
async with pool.acquire() as connection: async with pool.acquire() as connection:
await update_project_status(connection, project_id, "WF1_ANALYZING")
await connection.commit()
stored_path = await get_project_storage_relative_path(connection, project_id) stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path)) project_root = Path(resolve_stored_project_path(stored_path))
input_file = await get_input_file(connection, project_id, request.input_file_id) input_file = await get_input_file(connection, project_id, request.input_file_id)
@@ -133,6 +157,7 @@ async def analyze_surface(
analysis_result=result, analysis_result=result,
source_filters=source_filters, source_filters=source_filters,
) )
await update_project_status(connection, project_id, "WF1_COMPLETE")
await connection.commit() await connection.commit()
except Exception: except Exception:
await connection.rollback() await connection.rollback()
@@ -147,9 +172,15 @@ async def analyze_surface(
except LookupError as exc: except LookupError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except (OSError, ValueError) as exc: except (OSError, ValueError) as exc:
async with pool.acquire() as connection:
await update_project_status(connection, project_id, "WF1_FAILED")
await connection.commit()
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except Exception: except Exception:
logger.exception("B04 지표면 분석 실패: project_id=%s", project_id) logger.exception("B04 지표면 분석 실패: project_id=%s", project_id)
async with pool.acquire() as connection:
await update_project_status(connection, project_id, "WF1_FAILED")
await connection.commit()
return JSONResponse( return JSONResponse(
status_code=500, status_code=500,
content={"status": "error", "message": "지표면 분석 처리 중 오류가 발생했습니다."}, content={"status": "error", "message": "지표면 분석 처리 중 오류가 발생했습니다."},
@@ -175,6 +206,108 @@ async def get_surface_models(project_id: UUID) -> SurfaceModelListResponse | JSO
) )
@router.get("/{project_id}/surface/input-files", response_model=SurfaceInputFileListResponse)
async def get_surface_input_files(project_id: UUID) -> SurfaceInputFileListResponse | JSONResponse:
"""프로젝트의 WF1 분석 대상 LAS/LAZ 입력 파일 목록을 조회한다."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
files = await list_project_point_cloud_inputs(connection, project_id)
return SurfaceInputFileListResponse(
project_id=str(project_id),
files=[SurfaceInputFileSummary(**item) for item in files],
)
except Exception:
logger.exception("B04 입력 파일 목록 조회 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "입력 파일 목록 조회 중 오류가 발생했습니다."},
)
@router.get("/{project_id}/surface/point-cloud", response_model=SurfacePointCloudSampleResponse)
async def get_surface_point_cloud(
project_id: UUID,
) -> SurfacePointCloudSampleResponse | JSONResponse:
"""구조화된 LAS 결과에서 B04 3D 미리보기용 포인트 샘플을 반환한다."""
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))
structured_path = project_root / "B04_wf1_Surface" / "processed" / "structured.npz"
if not structured_path.is_file():
return JSONResponse(
status_code=404,
content={"status": "error", "message": "구조화된 포인트클라우드가 없습니다."},
)
with np.load(structured_path) as structured:
xyz = np.asarray(structured["xyz"], dtype=np.float32)
bounds = np.asarray(structured["bounds"], dtype=np.float64)
point_count = int(len(xyz))
if point_count > POINT_CLOUD_SAMPLE_LIMIT:
rng = np.random.default_rng(20260710)
indexes = rng.choice(point_count, POINT_CLOUD_SAMPLE_LIMIT, replace=False)
sample = xyz[indexes]
else:
sample = xyz
return SurfacePointCloudSampleResponse(
project_id=str(project_id),
point_count=point_count,
sampled_count=int(len(sample)),
bounds={
"x_min": float(bounds[0, 0]),
"x_max": float(bounds[0, 1]),
"y_min": float(bounds[1, 0]),
"y_max": float(bounds[1, 1]),
"z_min": float(bounds[2, 0]),
"z_max": float(bounds[2, 1]),
},
points=sample.astype(float).tolist(),
)
except LookupError as exc:
return JSONResponse(status_code=404, 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/ground-stats", response_model=SurfaceGroundStatsResponse)
async def get_surface_ground_stats(project_id: UUID) -> SurfaceGroundStatsResponse | JSONResponse:
"""manifest에서 필터별 지면 포인트 통계를 반환한다."""
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))
manifest_path = project_root / "B04_wf1_Surface" / "models" / "manifest.json"
if not manifest_path.is_file():
return SurfaceGroundStatsResponse(project_id=str(project_id), filters={})
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
filters = {
key: {
"source_point_count": value.get("source_point_count"),
"methods": value.get("methods", {}),
}
for key, value in manifest.get("source_filters", {}).items()
if isinstance(value, dict)
}
return SurfaceGroundStatsResponse(project_id=str(project_id), filters=filters)
except LookupError as exc:
return JSONResponse(status_code=404, 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/status") @router.get("/{project_id}/surface/status")
async def get_wf1_analysis_status(project_id: UUID) -> dict: async def get_wf1_analysis_status(project_id: UUID) -> dict:
"""WF1 분석 상태를 조회한다.""" """WF1 분석 상태를 조회한다."""
@@ -184,20 +317,50 @@ async def get_wf1_analysis_status(project_id: UUID) -> dict:
async with connection.cursor(aiomysql.DictCursor) as cursor: async with connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute( await cursor.execute(
""" """
SELECT COUNT(*) as model_count SELECT p.status as project_status, COUNT(sm.id) as model_count
FROM surface_models FROM projects p
WHERE project_id = %s LEFT JOIN surface_models sm ON sm.project_id = p.id
WHERE p.id = %s AND p.deleted_at IS NULL
GROUP BY p.id, p.status
""", """,
(str(project_id),), (str(project_id),),
) )
row = await cursor.fetchone() row = await cursor.fetchone()
if not row:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "프로젝트를 찾을 수 없습니다."},
)
model_count = int(row["model_count"]) if row else 0 model_count = int(row["model_count"]) if row else 0
status = "completed" if model_count > 0 else "in_progress" project_status = str(row.get("project_status") or "NEW")
if project_status == "WF1_FAILED":
status = "failed"
progress_percent = 0
current_stage = "failed"
message = "WF1 분석에 실패했습니다."
elif model_count > 0 or project_status == "WF1_COMPLETE":
status = "completed"
progress_percent = 100
current_stage = "completed"
message = "WF1 분석이 완료되었습니다."
elif project_status == "WF1_ANALYZING":
status = "in_progress"
progress_percent = 30
current_stage = "surface_analysis"
message = "WF1 분석이 진행 중입니다."
else:
status = "pending"
progress_percent = 0
current_stage = "pending"
message = "WF1 분석 대기 중입니다."
return { return {
"project_id": str(project_id), "project_id": str(project_id),
"status": status, "status": status,
"model_count": model_count, "model_count": model_count,
"progress_percent": progress_percent,
"current_stage": current_stage,
"message": message,
} }
except Exception: except Exception:
logger.exception("WF1 분석 상태 조회 실패: project_id=%s", project_id) logger.exception("WF1 분석 상태 조회 실패: project_id=%s", project_id)
+40
View File
@@ -53,6 +53,46 @@ class SurfaceModelSummary(BaseModel):
created_at: str | None = None created_at: str | None = None
class SurfaceInputFileSummary(BaseModel):
"""WF1 분석 대상으로 선택 가능한 LAS/LAZ 입력 파일 요약."""
id: int
file_type: str
original_filename: str
raw_file_path: str
file_size_mb: float | None = None
crs_epsg: int | None = None
status: str | None = None
created_at: str | None = None
class SurfaceInputFileListResponse(BaseModel):
"""프로젝트 지표면 분석 입력 파일 목록 응답."""
status: str = "success"
project_id: str
files: list[SurfaceInputFileSummary]
class SurfacePointCloudSampleResponse(BaseModel):
"""B04 3D 미리보기용 포인트클라우드 샘플."""
status: str = "success"
project_id: str
point_count: int
sampled_count: int
bounds: dict[str, float]
points: list[list[float]]
class SurfaceGroundStatsResponse(BaseModel):
"""필터별 지면 포인트 통계 응답."""
status: str = "success"
project_id: str
filters: dict[str, dict[str, Any]]
class SurfaceAnalyzeResponse(BaseModel): class SurfaceAnalyzeResponse(BaseModel):
"""지표면 분석 실행 결과.""" """지표면 분석 실행 결과."""
+289 -4
View File
@@ -25,9 +25,17 @@ import {
import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { workflowSteps } from "../A00_Common/b_page_scaffold";
import { import {
analyzeSurface, analyzeSurface,
fetchSurfaceGroundStats,
fetchSurfacePointCloud,
fetchSurfaceStatus,
listSurfaceInputFiles,
listSurfaceModels, listSurfaceModels,
type SurfaceInputFileSummary,
type SurfaceModelSummary, type SurfaceModelSummary,
type SurfaceStatusResponse,
} from "./B04_wf1_Surface_Api_Fetch"; } from "./B04_wf1_Surface_Api_Fetch";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { createSurfacePointCloudViewer } from "./B04_wf1_Surface_UI_Viewer";
import "./B04_wf1_Surface_UI_Style.css"; import "./B04_wf1_Surface_UI_Style.css";
/** locale 헬퍼 */ /** locale 헬퍼 */
@@ -73,7 +81,7 @@ function buildCheckboxGroup(
return { root, selected }; return { root, selected };
} }
export function renderB04Surface(root: HTMLElement): void { export function renderB04SurfaceLegacy(root: HTMLElement): void {
const shell = createWorkflowShell({ const shell = createWorkflowShell({
title: L("B04_Surface_Title"), title: L("B04_Surface_Title"),
steps: workflowSteps(), steps: workflowSteps(),
@@ -166,9 +174,7 @@ export function renderB04Surface(root: HTMLElement): void {
const meta = document.createElement("div"); const meta = document.createElement("div");
meta.className = "b04-surface__model-meta"; meta.className = "b04-surface__model-meta";
const resolution = document.createElement("span"); const resolution = document.createElement("span");
resolution.textContent = `${L("B04_Surface_Model_Resolution")}: ${ resolution.textContent = `${L("B04_Surface_Model_Resolution")}: ${model.resolution_m ?? "-"}`;
model.resolution_m ?? "-"
}`;
const path = document.createElement("span"); const path = document.createElement("span");
path.textContent = `${L("B04_Surface_Model_Path")}: ${model.model_file_path ?? "-"}`; path.textContent = `${L("B04_Surface_Model_Path")}: ${model.model_file_path ?? "-"}`;
meta.append(resolution, path); meta.append(resolution, path);
@@ -248,3 +254,282 @@ export function renderB04Surface(root: HTMLElement): void {
const initialProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); const initialProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (initialProjectId) void loadModels(initialProjectId); if (initialProjectId) void loadModels(initialProjectId);
} }
function buildInfoLine(label: string, value: unknown): HTMLElement {
const row = document.createElement("div");
row.className = "b04-surface__line";
const key = document.createElement("span");
key.textContent = label;
const val = document.createElement("strong");
val.textContent = value == null || value === "" ? "-" : String(value);
row.append(key, val);
return row;
}
export function renderB04Surface(root: HTMLElement): void {
let selectedInputFile: SurfaceInputFileSummary | null = null;
let inputFiles: SurfaceInputFileSummary[] = [];
const inputSelect = document.createElement("select");
inputSelect.className = "b04-surface__select";
const inputInfo = document.createElement("div");
inputInfo.className = "b04-surface__input-info";
const statusBox = document.createElement("div");
statusBox.className = "b04-surface__status";
const modelList = document.createElement("div");
modelList.className = "b04-surface__models";
const statsList = document.createElement("div");
statsList.className = "b04-surface__stats";
const viewer = createSurfacePointCloudViewer();
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 refreshButton = createButton({
label: L("B04_Surface_Btn_Refresh"),
variant: "ghost",
onClick: () => void onB04_Surface_Refresh_Click(),
});
const panel = document.createElement("div");
panel.className = "b04-surface__form";
const inputGroup = document.createElement("section");
inputGroup.className = "b04-surface__group";
const inputTitle = document.createElement("h3");
inputTitle.className = "b04-surface__panel-title";
inputTitle.textContent = L("B04_Surface_InputFiles");
inputGroup.append(inputTitle, inputSelect, inputInfo);
panel.append(
inputGroup,
filterGroup.root,
methodGroup.root,
forceLabel,
analyzeButton,
refreshButton,
);
const workspace = document.createElement("div");
workspace.className = "b04-surface__workspace";
const topbar = document.createElement("div");
topbar.className = "b04-surface__topbar";
const title = document.createElement("h3");
title.textContent = L("B04_Surface_PointCloud_Title");
topbar.append(title, statusBox);
const bottom = document.createElement("div");
bottom.className = "b04-surface__bottom";
const statsSection = document.createElement("section");
statsSection.className = "b04-surface__section";
const statsTitle = document.createElement("h3");
statsTitle.textContent = L("B04_Surface_GroundStats_Title");
statsSection.append(statsTitle, statsList);
const modelsSection = document.createElement("section");
modelsSection.className = "b04-surface__section";
const modelsTitle = document.createElement("h3");
modelsTitle.textContent = L("B04_Surface_Result_Title");
modelsSection.append(modelsTitle, modelList);
bottom.append(statsSection, modelsSection);
workspace.append(topbar, viewer.root, bottom);
const layout = createWorkflowLayout({
title: L("B04_Surface_Title"),
steps: workflowSteps(),
activeStep: 1,
leftPanel: panel,
mainContent: workspace,
});
function getProjectId(): string | null {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (!projectId) showToast(L("B04_Surface_Error_Project"), "error");
return projectId;
}
function renderStatus(status: SurfaceStatusResponse | null): void {
statusBox.replaceChildren();
if (!status) {
statusBox.append(createTag(L("B04_Surface_Status_Unknown"), "neutral"));
return;
}
const variant =
status.status === "completed" ? "success" : status.status === "failed" ? "danger" : "warning";
statusBox.append(
createTag(`${status.progress_percent}%`, variant),
document.createTextNode(status.message),
);
}
function renderInputFiles(files: readonly SurfaceInputFileSummary[]): void {
inputFiles = [...files];
inputSelect.replaceChildren();
if (inputFiles.length === 0) {
selectedInputFile = null;
const option = document.createElement("option");
option.value = "";
option.textContent = L("B04_Surface_InputFiles_Empty");
inputSelect.append(option);
inputInfo.replaceChildren();
analyzeButton.disabled = true;
return;
}
selectedInputFile = inputFiles[0];
for (const file of inputFiles) {
const option = document.createElement("option");
option.value = String(file.id);
option.textContent = `${file.original_filename} (#${file.id})`;
inputSelect.append(option);
}
inputSelect.value = String(selectedInputFile.id);
analyzeButton.disabled = false;
renderInputInfo();
}
function renderInputInfo(): void {
inputInfo.replaceChildren();
if (!selectedInputFile) return;
inputInfo.append(
buildInfoLine(L("B04_Surface_Input_FileName"), selectedInputFile.original_filename),
buildInfoLine(L("B04_Surface_Input_Crs"), selectedInputFile.crs_epsg),
buildInfoLine(L("B04_Surface_Input_Size"), selectedInputFile.file_size_mb?.toFixed(2)),
);
}
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("article");
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" || model.status === "COMPLETE" ? "success" : "neutral";
head.append(type, createTag(model.status, variant));
const meta = document.createElement("div");
meta.className = "b04-surface__model-meta";
meta.append(
buildInfoLine(L("B04_Surface_Model_Resolution"), model.resolution_m),
buildInfoLine(L("B04_Surface_Model_Path"), model.model_file_path),
);
card.append(head, meta);
modelList.append(card);
}
}
function renderGroundStats(filters: Record<string, Record<string, unknown>>): void {
statsList.replaceChildren();
const entries = Object.entries(filters);
if (entries.length === 0) {
const empty = document.createElement("p");
empty.className = "b04-surface__empty";
empty.textContent = L("B04_Surface_GroundStats_Empty");
statsList.append(empty);
return;
}
for (const [name, value] of entries) {
const item = document.createElement("article");
item.className = "b04-surface__stat-card";
item.append(
buildInfoLine(L("B04_Surface_GroundStats_Filter"), name),
buildInfoLine(L("B04_Surface_GroundStats_SourcePoints"), value.source_point_count),
);
statsList.append(item);
}
}
async function loadProjectData(projectId: string): Promise<void> {
const [inputs, status, models, stats] = await Promise.all([
listSurfaceInputFiles(projectId),
fetchSurfaceStatus(projectId),
listSurfaceModels(projectId),
fetchSurfaceGroundStats(projectId),
]);
renderInputFiles(inputs.files);
renderStatus(status);
renderModels(models.models);
renderGroundStats(stats.filters);
try {
viewer.render(await fetchSurfacePointCloud(projectId));
} catch {
viewer.render(null);
}
}
async function onB04_Surface_Analyze_Click(): Promise<void> {
const projectId = getProjectId();
if (!projectId || !selectedInputFile) return;
if (filterGroup.selected.size === 0 || methodGroup.selected.size === 0) {
showToast(L("B04_Surface_Error_Selection"), "error");
return;
}
showLoadingOverlay();
try {
const response = await analyzeSurface(projectId, {
input_file_id: selectedInputFile.id,
source_filters: [...filterGroup.selected],
methods: [...methodGroup.selected],
force: forceBox.checked,
});
showToast(
`${L("B04_Surface_Analyze_Success")} (${response.surface_model_ids.length})`,
"success",
);
await loadProjectData(projectId);
} catch (error) {
const detail = error instanceof Error ? error.message : L("B04_Surface_Analyze_Failed");
showToast(`${L("B04_Surface_Analyze_Failed")} ${detail}`, "error");
} finally {
hideLoadingOverlay();
}
}
async function onB04_Surface_Refresh_Click(): Promise<void> {
const projectId = getProjectId();
if (!projectId) return;
showLoadingOverlay();
try {
await loadProjectData(projectId);
} catch {
showToast(L("B04_Surface_Load_Failed"), "error");
} finally {
hideLoadingOverlay();
}
}
inputSelect.addEventListener("change", () => {
selectedInputFile = inputFiles.find((file) => String(file.id) === inputSelect.value) ?? null;
renderInputInfo();
});
root.replaceChildren(layout.root);
const projectId = getProjectId();
if (projectId) void onB04_Surface_Refresh_Click();
}
@@ -12,6 +12,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-16); gap: var(--spacing-16);
padding: var(--spacing-16);
} }
.b04-surface__group { .b04-surface__group {
@@ -45,7 +46,99 @@
accent-color: var(--color-primary); accent-color: var(--color-primary);
} }
.b04-surface__panel-title {
font-size: var(--text-body-sm);
color: var(--color-text);
}
.b04-surface__select {
width: 100%;
min-height: 38px;
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background: var(--color-canvas);
color: var(--color-text-body);
padding: var(--spacing-8);
}
.b04-surface__input-info,
.b04-surface__model-meta,
.b04-surface__stats {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.b04-surface__line {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-16);
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.b04-surface__line strong {
color: var(--color-text-body);
text-align: right;
word-break: break-word;
}
/* --- 우측 결과 영역 --- */ /* --- 우측 결과 영역 --- */
.b04-surface__workspace {
display: grid;
grid-template-rows: auto minmax(360px, 1fr) auto;
min-height: calc(100vh - var(--wf-header-height));
}
.b04-surface__topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-16);
padding: var(--spacing-16) var(--spacing-24);
border-bottom: 1px solid var(--color-border);
}
.b04-surface__topbar h3,
.b04-surface__section h3 {
font-size: var(--text-body);
}
.b04-surface__status {
display: flex;
align-items: center;
gap: var(--spacing-8);
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.b04-surface-viewer {
min-height: 360px;
height: 100%;
}
.b04-surface-viewer__canvas {
display: block;
width: 100%;
height: 100%;
}
.b04-surface__bottom {
display: grid;
grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
gap: var(--spacing-16);
padding: var(--spacing-16) var(--spacing-24) var(--spacing-24);
border-top: 1px solid var(--color-border);
}
.b04-surface__section {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
min-width: 0;
}
.b04-surface__result { .b04-surface__result {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -86,6 +179,16 @@
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
.b04-surface__stat-card {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-16);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background-color: var(--color-surface-raised);
}
.b04-surface__model-head { .b04-surface__model-head {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -107,3 +210,9 @@
color: var(--color-text-secondary); color: var(--color-text-secondary);
word-break: break-all; word-break: break-all;
} }
@media (max-width: 980px) {
.b04-surface__bottom {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,121 @@
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { RENDER_OPTIONS } from "@config/config_frontend";
import type { SurfacePointCloudSampleResponse } from "./B04_wf1_Surface_Api_Fetch";
export interface SurfacePointCloudViewer {
root: HTMLElement;
render: (data: SurfacePointCloudSampleResponse | null) => void;
dispose: () => void;
}
export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
const root = document.createElement("div");
root.className = "b04-surface-viewer";
const canvas = document.createElement("canvas");
canvas.className = "b04-surface-viewer__canvas";
root.append(canvas);
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: RENDER_OPTIONS.antialias,
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, RENDER_OPTIONS.maxPixelRatio));
const scene = new THREE.Scene();
scene.background = new THREE.Color(RENDER_OPTIONS.canvasClearColorLight);
const camera = new THREE.PerspectiveCamera(
RENDER_OPTIONS.cameraFov,
1,
RENDER_OPTIONS.cameraNear,
RENDER_OPTIONS.cameraFar,
);
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
const grid = new THREE.GridHelper(
200,
20,
RENDER_OPTIONS.gridCenterColor,
RENDER_OPTIONS.gridLineColor,
);
scene.add(grid);
let pointsObject: THREE.Points | null = null;
let animationFrame = 0;
function resize(): void {
const rect = root.getBoundingClientRect();
const width = Math.max(1, Math.floor(rect.width));
const height = Math.max(1, Math.floor(rect.height));
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
function animate(): void {
resize();
controls.update();
renderer.render(scene, camera);
animationFrame = requestAnimationFrame(animate);
}
function clearPoints(): void {
if (!pointsObject) return;
pointsObject.geometry.dispose();
const material = pointsObject.material;
if (Array.isArray(material)) material.forEach((item) => item.dispose());
else material.dispose();
scene.remove(pointsObject);
pointsObject = null;
}
function render(data: SurfacePointCloudSampleResponse | null): void {
clearPoints();
if (!data || data.points.length === 0) return;
const bounds = data.bounds;
const cx = ((bounds.x_min ?? 0) + (bounds.x_max ?? 0)) / 2;
const cy = ((bounds.y_min ?? 0) + (bounds.y_max ?? 0)) / 2;
const cz = ((bounds.z_min ?? 0) + (bounds.z_max ?? 0)) / 2;
const positions = new Float32Array(data.points.length * 3);
data.points.forEach((point, index) => {
positions[index * 3] = point[0] - cx;
positions[index * 3 + 1] = point[2] - cz;
positions[index * 3 + 2] = point[1] - cy;
});
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.computeBoundingSphere();
const material = new THREE.PointsMaterial({
color: RENDER_OPTIONS.pointColor,
size: 1.6,
sizeAttenuation: false,
});
pointsObject = new THREE.Points(geometry, material);
scene.add(pointsObject);
const radius = geometry.boundingSphere?.radius ?? 100;
camera.position.set(radius * 0.9, radius * 0.7, radius * 1.4);
camera.near = Math.max(RENDER_OPTIONS.cameraNear, radius / 10000);
camera.far = Math.max(RENDER_OPTIONS.cameraFar, radius * 8);
camera.updateProjectionMatrix();
controls.target.set(0, 0, 0);
controls.update();
}
animationFrame = requestAnimationFrame(animate);
return {
root,
render,
dispose: () => {
cancelAnimationFrame(animationFrame);
clearPoints();
controls.dispose();
renderer.dispose();
},
};
}
+9 -10
View File
@@ -17,12 +17,12 @@ import {
createButton, createButton,
createInputField, createInputField,
createSelectField, createSelectField,
createWorkflowShell,
hideLoadingOverlay, hideLoadingOverlay,
showLoadingOverlay, showLoadingOverlay,
showToast, showToast,
type InputFieldHandle, type InputFieldHandle,
} from "@ui/ui_template_elements"; } from "@ui/ui_template_elements";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { workflowSteps } from "../A00_Common/b_page_scaffold";
import { import {
confirmRoute, confirmRoute,
@@ -71,12 +71,6 @@ function parseNumber(value: string): number | null {
} }
export function renderB05Route(root: HTMLElement): void { export function renderB05Route(root: HTMLElement): void {
const shell = createWorkflowShell({
title: L("B05_Route_Title"),
steps: workflowSteps(),
activeStep: 1,
});
/* ---- 좌측: 경로 제어점 ---- */ /* ---- 좌측: 경로 제어점 ---- */
const pointsGroup = document.createElement("fieldset"); const pointsGroup = document.createElement("fieldset");
pointsGroup.className = "b05-route__group"; pointsGroup.className = "b05-route__group";
@@ -193,7 +187,6 @@ export function renderB05Route(root: HTMLElement): void {
const leftForm = document.createElement("div"); const leftForm = document.createElement("div");
leftForm.className = "b05-route__form"; leftForm.className = "b05-route__form";
leftForm.append(pointsGroup, surfaceGroup, constraintGroup, actionRow); leftForm.append(pointsGroup, surfaceGroup, constraintGroup, actionRow);
shell.leftPanel.append(leftForm);
/* ---- 우측: 결과 ---- */ /* ---- 우측: 결과 ---- */
const resultTitle = document.createElement("h3"); const resultTitle = document.createElement("h3");
@@ -240,7 +233,6 @@ export function renderB05Route(root: HTMLElement): void {
const resultCard = document.createElement("div"); const resultCard = document.createElement("div");
resultCard.className = "b05-route__result"; resultCard.className = "b05-route__result";
resultCard.append(resultTitle, resultBody); resultCard.append(resultTitle, resultBody);
shell.rightArea.append(resultCard);
/* ---- 이벤트 핸들러 ---- */ /* ---- 이벤트 핸들러 ---- */
function getProjectId(): string | null { function getProjectId(): string | null {
@@ -326,5 +318,12 @@ export function renderB05Route(root: HTMLElement): void {
} }
renderEmptyResult(); renderEmptyResult();
root.replaceChildren(shell.root); const layout = createWorkflowLayout({
title: L("B05_Route_Title"),
steps: workflowSteps(),
activeStep: 2,
leftPanel: leftForm,
mainContent: resultCard,
});
root.replaceChildren(layout.root);
} }
@@ -16,11 +16,11 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { import {
createButton, createButton,
createInputField, createInputField,
createWorkflowShell,
hideLoadingOverlay, hideLoadingOverlay,
showLoadingOverlay, showLoadingOverlay,
showToast, showToast,
} from "@ui/ui_template_elements"; } from "@ui/ui_template_elements";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { workflowSteps } from "../A00_Common/b_page_scaffold";
import { import {
confirmSections, confirmSections,
@@ -54,12 +54,6 @@ function parseNumber(value: string): number | null {
} }
export function renderB06ProfileCross(root: HTMLElement): void { export function renderB06ProfileCross(root: HTMLElement): void {
const shell = createWorkflowShell({
title: L("B06_Profile_Title"),
steps: workflowSteps(),
activeStep: 2,
});
let currentRouteId: number | null = null; let currentRouteId: number | null = null;
/* ---- 좌측: 대상 경로 ---- */ /* ---- 좌측: 대상 경로 ---- */
@@ -134,7 +128,6 @@ export function renderB06ProfileCross(root: HTMLElement): void {
const leftForm = document.createElement("div"); const leftForm = document.createElement("div");
leftForm.className = "b06-profile__form"; leftForm.className = "b06-profile__form";
leftForm.append(routeGroup, optionGroup, actionRow); leftForm.append(routeGroup, optionGroup, actionRow);
shell.leftPanel.append(leftForm);
/* ---- 우측: 결과 ---- */ /* ---- 우측: 결과 ---- */
const resultTitle = document.createElement("h3"); const resultTitle = document.createElement("h3");
@@ -176,7 +169,6 @@ export function renderB06ProfileCross(root: HTMLElement): void {
const resultCard = document.createElement("div"); const resultCard = document.createElement("div");
resultCard.className = "b06-profile__result"; resultCard.className = "b06-profile__result";
resultCard.append(resultTitle, resultBody); resultCard.append(resultTitle, resultBody);
shell.rightArea.append(resultCard);
/* ---- 이벤트 핸들러 ---- */ /* ---- 이벤트 핸들러 ---- */
function getProjectId(): string | null { function getProjectId(): string | null {
@@ -248,5 +240,12 @@ export function renderB06ProfileCross(root: HTMLElement): void {
} }
renderEmptyResult(); renderEmptyResult();
root.replaceChildren(shell.root); const layout = createWorkflowLayout({
title: L("B06_Profile_Title"),
steps: workflowSteps(),
activeStep: 3,
leftPanel: leftForm,
mainContent: resultCard,
});
root.replaceChildren(layout.root);
} }
+25
View File
@@ -5,6 +5,18 @@ from pathlib import PurePosixPath
from config.config_system import PROJECT_STORAGE_STAGE_DIRS, STORAGE_BASE_DIR from config.config_system import PROJECT_STORAGE_STAGE_DIRS, STORAGE_BASE_DIR
PROJECT_STORAGE_LAYOUT_V2 = (
("B03_FileInput", "input"),
("B04_wf1_Surface", "processed"),
("B04_wf1_Surface", "models"),
("B05_wf2_Route", "route"),
("B06_wf3_ProfileCross", "longitudinal"),
("B06_wf3_ProfileCross", "cross_sections"),
("B07_wf4_DesignDetail", "structures"),
("B08_wf5_Quantity", "quantities"),
("B09_wf6_Estimation", "v1"),
)
def get_project_stage_path(project_root: str, stage: str) -> str: def get_project_stage_path(project_root: str, stage: str) -> str:
"""프로젝트 루트 아래의 허용된 워크플로우 단계 폴더를 생성한다.""" """프로젝트 루트 아래의 허용된 워크플로우 단계 폴더를 생성한다."""
@@ -20,6 +32,18 @@ def get_project_stage_path(project_root: str, stage: str) -> str:
return path return path
def ensure_project_storage_layout(project_root: str) -> None:
"""신규 워크플로우 저장소 하위 폴더를 누락분만 생성한다."""
root = os.path.abspath(project_root)
for stage, subdir in PROJECT_STORAGE_LAYOUT_V2:
if stage not in PROJECT_STORAGE_STAGE_DIRS:
raise ValueError(f"허용되지 않은 프로젝트 저장 단계입니다: {stage}")
path = os.path.abspath(os.path.join(root, stage, subdir))
if os.path.commonpath((root, path)) != root:
raise ValueError("단계 저장 경로가 프로젝트 루트를 벗어났습니다.")
os.makedirs(path, exist_ok=True)
def resolve_stored_project_path(relative_path: str) -> str: def resolve_stored_project_path(relative_path: str) -> str:
"""DB의 storage 기준 상대 경로를 검증해 실제 프로젝트 경로로 변환한다.""" """DB의 storage 기준 상대 경로를 검증해 실제 프로젝트 경로로 변환한다."""
normalized = PurePosixPath(relative_path.replace("\\", "/")) normalized = PurePosixPath(relative_path.replace("\\", "/"))
@@ -33,4 +57,5 @@ def resolve_stored_project_path(relative_path: str) -> str:
if os.path.commonpath((storage_root, path)) != storage_root or path == storage_root: if os.path.commonpath((storage_root, path)) != storage_root or path == storage_root:
raise ValueError("프로젝트 저장 경로가 저장소 루트를 벗어났습니다.") raise ValueError("프로젝트 저장 경로가 저장소 루트를 벗어났습니다.")
os.makedirs(path, exist_ok=True) os.makedirs(path, exist_ok=True)
ensure_project_storage_layout(path)
return path return path
+3
View File
@@ -47,6 +47,9 @@ export const RENDER_OPTIONS = {
/** Three.js 배경색 (theme.css 변수와 별개로 캔버스 clear color) */ /** Three.js 배경색 (theme.css 변수와 별개로 캔버스 clear color) */
canvasClearColorLight: 0xf6f7fa, canvasClearColorLight: 0xf6f7fa,
canvasClearColorDark: 0x16121f, canvasClearColorDark: 0x16121f,
pointColor: 0x3e0079,
gridCenterColor: 0x9491a1,
gridLineColor: 0xe6e2e3,
/** 카메라 기본 시야각 */ /** 카메라 기본 시야각 */
cameraFov: 60, cameraFov: 60,
/** 카메라 near/far */ /** 카메라 near/far */
+23
View File
@@ -544,6 +544,14 @@ export const ui_locales = {
B03_File_Error_Size: ["파일 크기 제한을 초과했습니다.", "File size limit exceeded."], B03_File_Error_Size: ["파일 크기 제한을 초과했습니다.", "File size limit exceeded."],
B03_File_Upload_Success: ["입력 파일 업로드를 완료했습니다.", "Input files uploaded."], B03_File_Upload_Success: ["입력 파일 업로드를 완료했습니다.", "Input files uploaded."],
B03_File_Upload_Failed: ["파일 업로드에 실패했습니다.", "File upload failed."], B03_File_Upload_Failed: ["파일 업로드에 실패했습니다.", "File upload failed."],
B03_File_Analysis_InProgress: [
"WF1 분석이 백그라운드에서 진행 중입니다. 완료되면 자동으로 이동합니다.",
"WF1 analysis is running in the background. You will move automatically when it completes.",
],
B03_File_Analysis_StillRunning: [
"분석이 계속 진행 중입니다. 잠시 후 다시 확인하세요.",
"Analysis is still running. Check again shortly.",
],
B03_File_Result_Path: ["저장 경로", "Stored path"], B03_File_Result_Path: ["저장 경로", "Stored path"],
B03_File_Group_Required: ["필수 파일", "Required files"], B03_File_Group_Required: ["필수 파일", "Required files"],
B03_File_Group_Optional: ["선택 파일", "Optional files"], B03_File_Group_Optional: ["선택 파일", "Optional files"],
@@ -574,6 +582,7 @@ export const ui_locales = {
B03_File_Status_Completed: ["완료", "Completed"], B03_File_Status_Completed: ["완료", "Completed"],
B03_File_Status_Failed: ["실패", "Failed"], B03_File_Status_Failed: ["실패", "Failed"],
B03_File_Status_Detected: ["중단된 업로드 감지", "Paused upload detected"], B03_File_Status_Detected: ["중단된 업로드 감지", "Paused upload detected"],
B03_File_Restore_State: ["저장된 업로드/분석 상태 복구", "Restored upload/analysis state"],
B03_File_Resume_Button: ["업로드 재개", "Resume upload"], B03_File_Resume_Button: ["업로드 재개", "Resume upload"],
B03_File_New_Button: ["새 파일로 시작", "Start new file"], B03_File_New_Button: ["새 파일로 시작", "Start new file"],
B03_File_ServiceWorker_Ready: [ B03_File_ServiceWorker_Ready: [
@@ -594,6 +603,20 @@ export const ui_locales = {
B04_Surface_Field_Force: ["기존 결과 무시하고 재계산", "Force recompute"], B04_Surface_Field_Force: ["기존 결과 무시하고 재계산", "Force recompute"],
B04_Surface_Btn_Analyze: ["지표면 분석 실행", "Run Surface Analysis"], B04_Surface_Btn_Analyze: ["지표면 분석 실행", "Run Surface Analysis"],
B04_Surface_Btn_Refresh: ["목록 새로고침", "Refresh"], B04_Surface_Btn_Refresh: ["목록 새로고침", "Refresh"],
B04_Surface_InputFiles: ["입력 포인트클라우드", "Input point cloud"],
B04_Surface_InputFiles_Empty: [
"분석 가능한 LAS/LAZ 파일이 없습니다.",
"No LAS/LAZ file is available.",
],
B04_Surface_Input_FileName: ["파일명", "File name"],
B04_Surface_Input_Crs: ["좌표계", "CRS"],
B04_Surface_Input_Size: ["크기(MB)", "Size (MB)"],
B04_Surface_PointCloud_Title: ["포인트클라우드 미리보기", "Point cloud preview"],
B04_Surface_Status_Unknown: ["상태 미확인", "Unknown"],
B04_Surface_GroundStats_Title: ["지면 필터 통계", "Ground filter stats"],
B04_Surface_GroundStats_Empty: ["표시할 지면 통계가 없습니다.", "No ground stats to display."],
B04_Surface_GroundStats_Filter: ["필터", "Filter"],
B04_Surface_GroundStats_SourcePoints: ["지면 포인트", "Ground points"],
B04_Surface_Result_Title: ["생성된 지표면 모델", "Generated Surface Models"], B04_Surface_Result_Title: ["생성된 지표면 모델", "Generated Surface Models"],
B04_Surface_Result_Empty: [ B04_Surface_Result_Empty: [
"아직 생성된 지표면 모델이 없습니다.", "아직 생성된 지표면 모델이 없습니다.",
+117
View File
@@ -0,0 +1,117 @@
.ui-workflow-layout {
display: flex;
flex-direction: column;
min-height: calc(100vh - var(--wf-header-height));
background: var(--color-bg);
}
.ui-workflow-layout__header {
display: flex;
align-items: center;
gap: var(--spacing-16);
min-height: var(--wf-header-height);
padding: var(--spacing-8) var(--spacing-24);
border-bottom: 1px solid var(--color-border);
background: var(--color-surface-raised);
}
.ui-workflow-layout__menu-button {
width: 40px;
height: 40px;
border: 1px solid var(--color-border);
border-radius: var(--radius-buttons);
background: var(--color-canvas);
color: var(--color-text);
cursor: pointer;
}
.ui-workflow-layout__title-wrap {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-24);
flex: 1;
min-width: 0;
}
.ui-workflow-layout__title {
font-size: var(--text-subheading);
white-space: nowrap;
}
.ui-workflow-layout__steps {
display: flex;
gap: var(--spacing-8);
overflow-x: auto;
}
.ui-workflow-layout__step {
flex: 0 0 auto;
padding: var(--spacing-4) var(--spacing-16);
border-radius: var(--radius-pills);
background: var(--color-paper);
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.ui-workflow-layout__step.is-active {
background: var(--color-mist-violet);
color: var(--color-accent);
}
.ui-workflow-layout__body {
position: relative;
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
flex: 1;
min-height: 0;
}
.ui-workflow-layout__panel {
z-index: var(--z-dropdown);
overflow-y: auto;
border-right: 1px solid var(--color-border);
background: var(--color-surface);
transition:
margin-left var(--transition-base),
box-shadow var(--transition-base);
}
.ui-workflow-layout:not(.is-menu-open) .ui-workflow-layout__panel {
margin-left: -280px;
}
.ui-workflow-layout__main {
min-width: 0;
min-height: 0;
overflow: auto;
background: var(--color-canvas);
}
@media (max-width: 860px) {
.ui-workflow-layout__title-wrap {
align-items: flex-start;
flex-direction: column;
gap: var(--spacing-8);
}
.ui-workflow-layout__body {
display: block;
}
.ui-workflow-layout__panel {
position: absolute;
inset: 0 auto 0 0;
width: 280px;
box-shadow: var(--shadow-lg);
}
.ui-workflow-layout:not(.is-menu-open) .ui-workflow-layout__panel {
margin-left: -280px;
box-shadow: none;
}
.ui-workflow-layout__main {
min-height: calc(100vh - var(--wf-header-height));
}
}
@@ -0,0 +1,79 @@
import "./ui_template_workflow_layout.css";
export interface WorkflowLayoutOptions {
title: string;
steps: string[];
activeStep: number;
leftPanel: HTMLElement;
mainContent: HTMLElement;
onMenuToggle?: (isOpen: boolean) => void;
}
export interface WorkflowLayoutHandle {
root: HTMLElement;
setMenuOpen: (isOpen: boolean) => void;
}
function createStepBar(steps: readonly string[], activeStep: number): HTMLElement {
const bar = document.createElement("div");
bar.className = "ui-workflow-layout__steps";
steps.forEach((step, index) => {
const item = document.createElement("span");
item.className = "ui-workflow-layout__step";
if (index === activeStep) item.classList.add("is-active");
item.textContent = step;
bar.append(item);
});
return bar;
}
export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLayoutHandle {
const root = document.createElement("div");
root.className = "ui-workflow-layout";
const header = document.createElement("header");
header.className = "ui-workflow-layout__header";
const menuButton = document.createElement("button");
menuButton.type = "button";
menuButton.className = "ui-workflow-layout__menu-button";
menuButton.setAttribute("aria-label", "Toggle workflow menu");
menuButton.textContent = "☰";
const titleWrap = document.createElement("div");
titleWrap.className = "ui-workflow-layout__title-wrap";
const title = document.createElement("h2");
title.className = "ui-workflow-layout__title";
title.textContent = options.title;
titleWrap.append(title, createStepBar(options.steps, options.activeStep));
header.append(menuButton, titleWrap);
const body = document.createElement("div");
body.className = "ui-workflow-layout__body";
const aside = document.createElement("aside");
aside.className = "ui-workflow-layout__panel";
aside.append(options.leftPanel);
const main = document.createElement("main");
main.className = "ui-workflow-layout__main";
main.append(options.mainContent);
body.append(aside, main);
root.append(header, body);
function setMenuOpen(isOpen: boolean): void {
root.classList.toggle("is-menu-open", isOpen);
menuButton.setAttribute("aria-expanded", String(isOpen));
options.onMenuToggle?.(isOpen);
}
menuButton.addEventListener("click", () => setMenuOpen(!root.classList.contains("is-menu-open")));
main.addEventListener("click", () => {
if (root.classList.contains("is-menu-open")) setMenuOpen(false);
});
setMenuOpen(true);
return { root, setMenuOpen };
}
View File
+9
View File
@@ -0,0 +1,9 @@
> forest-road-webapp@0.1.0 dev
> vite --host 127.0.0.1
Port 5173 is in use, trying another one...
VITE v8.1.0 ready in 295 ms
➜ Local: http://127.0.0.1:5174/