diff --git a/B04_PreProcess/B04_PreProcess_Router.py b/B04_PreProcess/B04_PreProcess_Router.py index 8615af91..c4e6ad6a 100644 --- a/B04_PreProcess/B04_PreProcess_Router.py +++ b/B04_PreProcess/B04_PreProcess_Router.py @@ -7,10 +7,9 @@ from pathlib import Path from typing import Any from uuid import UUID -import aiomysql import numpy as np -from fastapi import APIRouter, Depends, Request -from fastapi.responses import JSONResponse, Response +from fastapi import APIRouter, Depends +from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B04_PreProcess.B04_PreProcess_Engine import ( @@ -30,6 +29,10 @@ from B04_PreProcess.B04_PreProcess_Repository import ( list_surface_models, save_surface_analysis_to_db, ) +from B04_PreProcess.B04_PreProcess_Router_Progress import ( + write_surface_progress, +) +from B04_PreProcess.B04_PreProcess_Router_Status import router as status_router from B04_PreProcess.B04_PreProcess_Schema import ( SurfaceAnalyzeRequest, SurfaceAnalyzeResponse, @@ -45,9 +48,6 @@ from B04_PreProcess.B04_PreProcess_Schema import ( ) from B04_PreProcess.B04_PreProcess_Service import confirm_surface_selection from common_util.common_util_auth import require_system_admin -from common_util.common_util_http_cache import cached_file_response -from common_util.common_util_initial_snapshot import is_designing -from common_util.common_util_json import atomic_write_json from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import ( get_surface_confirmation_params, @@ -63,37 +63,10 @@ from config.config_db import get_db_pool logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B04 Surface Analysis"]) POINT_CLOUD_SAMPLE_LIMIT = 500_000 -# 분석 진행률 파일: B04 산출 폴더 아래에 원자적으로 기록/조회한다. -PROGRESS_FILE_RELATIVE = ("B04_PreProcess", "processed", "progress.json") - -def _progress_file_path(project_root: Path) -> Path: - return project_root.joinpath(*PROGRESS_FILE_RELATIVE) - - -def write_surface_progress(project_root: Path, percent: int, stage: str, message: str) -> None: - """WF1 분석 진행률을 progress.json에 원자적으로 기록한다 (실패해도 분석은 계속).""" - try: - path = _progress_file_path(project_root) - path.parent.mkdir(parents=True, exist_ok=True) - atomic_write_json( - path, - {"progress_percent": percent, "current_stage": stage, "message": message}, - ) - except OSError: - logger.warning("WF1 진행률 기록 실패: %s", project_root, exc_info=True) - - -def read_surface_progress(project_root: Path) -> dict[str, Any] | None: - """progress.json을 읽어 반환한다. 없거나 손상 시 None.""" - path = _progress_file_path(project_root) - if not path.is_file(): - return None - try: - data = json.loads(path.read_text(encoding="utf-8")) - return data if isinstance(data, dict) else None - except (OSError, ValueError): - return None +# 상태 조회·모델 프리뷰 엔드포인트는 700줄 제한으로 `_Router_Status` 로 떼어내 +# 여기서 그대로 실어 붙인다(경로·응답 불변, 2026-09-04). +router.include_router(status_router) @router.post("/{project_id}/surface/analyze", response_model=SurfaceAnalyzeResponse) @@ -518,201 +491,3 @@ async def get_surface_ground_stats(project_id: UUID) -> SurfaceGroundStatsRespon status_code=500, content={"status": "error", "message": "지면 통계 조회 중 오류가 발생했습니다."}, ) - - -@router.get("/{project_id}/surface/status") -async def get_wf1_analysis_status(project_id: UUID) -> dict: - """WF1 분석 상태를 조회한다.""" - pool = get_db_pool() - try: - async with pool.acquire() as connection: - async with connection.cursor(aiomysql.DictCursor) as cursor: - await cursor.execute( - """ - SELECT state, progress_percent, message, - (SELECT COUNT(*) FROM surface_models - WHERE project_id = %s) as model_count - FROM project_workflow_stages - WHERE project_id = %s AND stage_no = 1 - """, - (str(project_id), str(project_id)), - ) - row = await cursor.fetchone() - - # 만약 새 테이블에 정보가 없다면 기존 projects 테이블에서 조회 (백필 미작동 대비) - if not row: - await cursor.execute( - """ - SELECT p.status as project_status, COUNT(sm.id) as model_count - FROM projects p - 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),), - ) - fallback_row = await cursor.fetchone() - if not fallback_row: - return JSONResponse( - status_code=404, - content={"status": "error", "message": "프로젝트를 찾을 수 없습니다."}, - ) - model_count = int(fallback_row["model_count"]) - project_status = str(fallback_row.get("project_status") or "NEW") - - if project_status == "WF1_FAILED": - state = "FAILED" - progress_percent = 0 - message = "WF1 분석에 실패했습니다." - elif model_count > 0 or project_status == "WF1_COMPLETE": - state = "COMPLETE" - progress_percent = 100 - message = "WF1 분석이 완료되었습니다." - elif project_status == "WF1_ANALYZING": - state = "IN_PROGRESS" - progress_percent = 30 - message = "WF1 분석이 진행 중입니다." - else: - state = "NOT_STARTED" - progress_percent = 0 - message = "WF1 분석 대기 중입니다." - else: - state = row["state"] - progress_percent = row["progress_percent"] - message = row["message"] or "" - model_count = int(row["model_count"]) - - if state == "FAILED": - status = "failed" - current_stage = "failed" - if not message: - message = "WF1 분석에 실패했습니다." - elif state == "COMPLETE": - status = "completed" - progress_percent = 100 - current_stage = "completed" - if not message: - message = "WF1 분석이 완료되었습니다." - elif state == "IN_PROGRESS": - status = "in_progress" - current_stage = "surface_analysis" - if not message: - message = "WF1 분석이 진행 중입니다." - # 진행률 파일이 있으면 실제 단계별 진행률로 대체 - try: - async with pool.acquire() as connection: - stored_path = await get_project_storage_relative_path(connection, project_id) - progress = read_surface_progress(Path(resolve_stored_project_path(stored_path))) - if progress: - progress_percent = int(progress.get("progress_percent", progress_percent)) - current_stage = str(progress.get("current_stage", current_stage)) - message = str(progress.get("message", message)) - except LookupError: - pass - else: - status = "pending" - progress_percent = 0 - current_stage = "pending" - if not message: - message = "WF1 분석 대기 중입니다." - - # 초기 설계 체인이 도는 동안은 아직 들어갈 때가 아니다 — WF1(stage 1)이 COMPLETE라도 - # 마커가 있으면 진행 중으로 돌려준다(2026-08-29 사용자 확정, CLAUDE.md 5장). - # 여기서 덮어쓰는 이유: 위 분기는 stage 1만 보므로 체인 구간을 "완료"로 답한다. - try: - async with pool.acquire() as connection: - stored_path = await get_project_storage_relative_path(connection, project_id) - if stored_path and is_designing(Path(resolve_stored_project_path(stored_path))): - status = "in_progress" - current_stage = "initial_design" - message = "초기 설계를 계산하는 중입니다." - except (LookupError, OSError, ValueError): - pass - - return { - "project_id": str(project_id), - "status": status, - "model_count": model_count, - "progress_percent": progress_percent, - "current_stage": current_stage, - "message": message, - } - except Exception: - logger.exception("WF1 분석 상태 조회 실패: project_id=%s", project_id) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "분석 상태 조회 중 오류가 발생했습니다."}, - ) - - -@router.get("/{project_id}/surface/models/{model_id}/preview", response_model=None) -async def get_surface_model_preview( - request: Request, - project_id: UUID, - model_id: int, - smooth: bool = False, -) -> Response | JSONResponse: - """지표면 모델의 3D 프리뷰 파일(GLB/PLY)을 반환한다.""" - pool = get_db_pool() - try: - async with pool.acquire() as connection: - stored_path = await get_project_storage_relative_path(connection, project_id) - async with connection.cursor() as cursor: - await cursor.execute( - """ - SELECT model_type, model_file_path - FROM surface_models - WHERE id = %s AND project_id = %s - """, - (model_id, str(project_id)), - ) - row = await cursor.fetchone() - if not row: - return JSONResponse( - status_code=404, - content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."}, - ) - model_type, model_file_path = row[0], row[1] - project_root = Path(resolve_stored_project_path(stored_path)) - if not model_file_path: - return JSONResponse( - status_code=404, - content={"status": "error", "message": "모델 파일 경로가 없습니다."}, - ) - model_path = project_root / model_file_path - models_dir = model_path.parent - stem = model_path.stem - - ext = "ply" if model_type == "meshfree" else "glb" - if smooth and model_type in ("dtm", "tin"): - preview_filename = f"{stem}_smooth_preview.glb" - else: - preview_filename = f"{stem}_preview.{ext}" - - preview_path = models_dir / preview_filename - if not preview_path.is_file(): - return JSONResponse( - status_code=404, - content={ - "status": "error", - "message": "프리뷰 파일이 생성되지 않았거나 존재하지 않습니다.", - }, - ) - - media_type = "application/octet-stream" - if ext == "glb": - media_type = "model/gltf-binary" - elif ext == "ply": - media_type = "application/ply" - - # 브라우저가 이미 같은 파일을 갖고 있으면 304만 돌려준다(새로고침이 빨라진다). - return cached_file_response(request, preview_path, media_type, preview_filename) - - except Exception: - logger.exception( - "지표면 모델 프리뷰 조회 실패: project_id=%s, model_id=%s", project_id, model_id - ) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "프리뷰 파일 조회 중 오류가 발생했습니다."}, - ) diff --git a/B04_PreProcess/B04_PreProcess_Router_Progress.py b/B04_PreProcess/B04_PreProcess_Router_Progress.py new file mode 100644 index 00000000..58adfd6d --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_Router_Progress.py @@ -0,0 +1,47 @@ +"""B04 지표면 분석 진행률 파일 입출력. + +`B04_PreProcess_Router` 가 700줄을 넘겨 분리한 조각이다(2026-09-04). 라우터 본체와 +상태 조회 라우터가 **같은 함수**를 써야 해서 여기에 둔다 — 순환 임포트를 피하려고 +양쪽이 이 모듈을 바라본다. 동작·경로·값은 옮기기 전 그대로다. +""" + +import json +import logging +from pathlib import Path +from typing import Any + +from common_util.common_util_json import atomic_write_json + +logger = logging.getLogger(__name__) + +# 분석 진행률 파일: B04 산출 폴더 아래에 원자적으로 기록/조회한다. +PROGRESS_FILE_RELATIVE = ("B04_PreProcess", "processed", "progress.json") + + +def _progress_file_path(project_root: Path) -> Path: + return project_root.joinpath(*PROGRESS_FILE_RELATIVE) + + +def write_surface_progress(project_root: Path, percent: int, stage: str, message: str) -> None: + """WF1 분석 진행률을 progress.json에 원자적으로 기록한다 (실패해도 분석은 계속).""" + try: + path = _progress_file_path(project_root) + path.parent.mkdir(parents=True, exist_ok=True) + atomic_write_json( + path, + {"progress_percent": percent, "current_stage": stage, "message": message}, + ) + except OSError: + logger.warning("WF1 진행률 기록 실패: %s", project_root, exc_info=True) + + +def read_surface_progress(project_root: Path) -> dict[str, Any] | None: + """progress.json을 읽어 반환한다. 없거나 손상 시 None.""" + path = _progress_file_path(project_root) + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else None + except (OSError, ValueError): + return None diff --git a/B04_PreProcess/B04_PreProcess_Router_Status.py b/B04_PreProcess/B04_PreProcess_Router_Status.py new file mode 100644 index 00000000..b4461c28 --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_Router_Status.py @@ -0,0 +1,223 @@ +"""B04 지표면 분석 **상태 조회·모델 프리뷰** 라우터. + +`B04_PreProcess_Router` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-04). 경로·응답·로직은 +옮기기 전 그대로이고, 본체가 `include_router` 로 이 라우터를 그대로 실어 붙인다. +""" + +import logging +from pathlib import Path +from uuid import UUID + +import aiomysql +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse, Response + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B04_PreProcess.B04_PreProcess_Router_Progress import read_surface_progress +from common_util.common_util_http_cache import cached_file_response +from common_util.common_util_initial_snapshot import is_designing +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) +# prefix 는 본체(`_Router`)가 `include_router` 할 때 붙는다 — 여기서 또 주면 +# 경로가 `/api/projects/api/projects/...` 로 겹친다. +router = APIRouter(tags=["B04 Surface Analysis"]) + + +@router.get("/{project_id}/surface/status") +async def get_wf1_analysis_status(project_id: UUID) -> dict: + """WF1 분석 상태를 조회한다.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """ + SELECT state, progress_percent, message, + (SELECT COUNT(*) FROM surface_models + WHERE project_id = %s) as model_count + FROM project_workflow_stages + WHERE project_id = %s AND stage_no = 1 + """, + (str(project_id), str(project_id)), + ) + row = await cursor.fetchone() + + # 만약 새 테이블에 정보가 없다면 기존 projects 테이블에서 조회 (백필 미작동 대비) + if not row: + await cursor.execute( + """ + SELECT p.status as project_status, COUNT(sm.id) as model_count + FROM projects p + 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),), + ) + fallback_row = await cursor.fetchone() + if not fallback_row: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트를 찾을 수 없습니다."}, + ) + model_count = int(fallback_row["model_count"]) + project_status = str(fallback_row.get("project_status") or "NEW") + + if project_status == "WF1_FAILED": + state = "FAILED" + progress_percent = 0 + message = "WF1 분석에 실패했습니다." + elif model_count > 0 or project_status == "WF1_COMPLETE": + state = "COMPLETE" + progress_percent = 100 + message = "WF1 분석이 완료되었습니다." + elif project_status == "WF1_ANALYZING": + state = "IN_PROGRESS" + progress_percent = 30 + message = "WF1 분석이 진행 중입니다." + else: + state = "NOT_STARTED" + progress_percent = 0 + message = "WF1 분석 대기 중입니다." + else: + state = row["state"] + progress_percent = row["progress_percent"] + message = row["message"] or "" + model_count = int(row["model_count"]) + + if state == "FAILED": + status = "failed" + current_stage = "failed" + if not message: + message = "WF1 분석에 실패했습니다." + elif state == "COMPLETE": + status = "completed" + progress_percent = 100 + current_stage = "completed" + if not message: + message = "WF1 분석이 완료되었습니다." + elif state == "IN_PROGRESS": + status = "in_progress" + current_stage = "surface_analysis" + if not message: + message = "WF1 분석이 진행 중입니다." + # 진행률 파일이 있으면 실제 단계별 진행률로 대체 + try: + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + progress = read_surface_progress(Path(resolve_stored_project_path(stored_path))) + if progress: + progress_percent = int(progress.get("progress_percent", progress_percent)) + current_stage = str(progress.get("current_stage", current_stage)) + message = str(progress.get("message", message)) + except LookupError: + pass + else: + status = "pending" + progress_percent = 0 + current_stage = "pending" + if not message: + message = "WF1 분석 대기 중입니다." + + # 초기 설계 체인이 도는 동안은 아직 들어갈 때가 아니다 — WF1(stage 1)이 COMPLETE라도 + # 마커가 있으면 진행 중으로 돌려준다(2026-08-29 사용자 확정, CLAUDE.md 5장). + # 여기서 덮어쓰는 이유: 위 분기는 stage 1만 보므로 체인 구간을 "완료"로 답한다. + try: + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + if stored_path and is_designing(Path(resolve_stored_project_path(stored_path))): + status = "in_progress" + current_stage = "initial_design" + message = "초기 설계를 계산하는 중입니다." + except (LookupError, OSError, ValueError): + pass + + return { + "project_id": str(project_id), + "status": status, + "model_count": model_count, + "progress_percent": progress_percent, + "current_stage": current_stage, + "message": message, + } + except Exception: + logger.exception("WF1 분석 상태 조회 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "분석 상태 조회 중 오류가 발생했습니다."}, + ) + + +@router.get("/{project_id}/surface/models/{model_id}/preview", response_model=None) +async def get_surface_model_preview( + request: Request, + project_id: UUID, + model_id: int, + smooth: bool = False, +) -> Response | JSONResponse: + """지표면 모델의 3D 프리뷰 파일(GLB/PLY)을 반환한다.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + async with connection.cursor() as cursor: + await cursor.execute( + """ + SELECT model_type, model_file_path + FROM surface_models + WHERE id = %s AND project_id = %s + """, + (model_id, str(project_id)), + ) + row = await cursor.fetchone() + if not row: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."}, + ) + model_type, model_file_path = row[0], row[1] + project_root = Path(resolve_stored_project_path(stored_path)) + if not model_file_path: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "모델 파일 경로가 없습니다."}, + ) + model_path = project_root / model_file_path + models_dir = model_path.parent + stem = model_path.stem + + ext = "ply" if model_type == "meshfree" else "glb" + if smooth and model_type in ("dtm", "tin"): + preview_filename = f"{stem}_smooth_preview.glb" + else: + preview_filename = f"{stem}_preview.{ext}" + + preview_path = models_dir / preview_filename + if not preview_path.is_file(): + return JSONResponse( + status_code=404, + content={ + "status": "error", + "message": "프리뷰 파일이 생성되지 않았거나 존재하지 않습니다.", + }, + ) + + media_type = "application/octet-stream" + if ext == "glb": + media_type = "model/gltf-binary" + elif ext == "ply": + media_type = "application/ply" + + # 브라우저가 이미 같은 파일을 갖고 있으면 304만 돌려준다(새로고침이 빨라진다). + return cached_file_response(request, preview_path, media_type, preview_filename) + + except Exception: + logger.exception( + "지표면 모델 프리뷰 조회 실패: project_id=%s, model_id=%s", project_id, model_id + ) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "프리뷰 파일 조회 중 오류가 발생했습니다."}, + )