"""B04 지표면 분석 FastAPI 라우터.""" import asyncio import json import logging from pathlib import Path from typing import Any from uuid import UUID import aiomysql import numpy as np from fastapi import APIRouter, HTTPException, Response from fastapi.responses import FileResponse, JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B04_wf1_Surface.B04_wf1_Surface_Engine import run_surface_analysis from B04_wf1_Surface.B04_wf1_Surface_Repository import ( create_processed_point_cloud, create_surface_model, create_terrain_layer, get_input_file, list_project_point_cloud_inputs, list_surface_models, ) from B04_wf1_Surface.B04_wf1_Surface_Schema import ( SurfaceAnalyzeRequest, SurfaceAnalyzeResponse, SurfaceGroundStatsResponse, SurfaceInputFileListResponse, SurfaceInputFileSummary, SurfaceModelListResponse, SurfaceModelSummary, SurfacePointCloudSampleResponse, ) from common_util.common_util_json import atomic_write_json from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_workflow_state import complete_stage, fail_stage, start_stage 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 = 100_000 # 분석 진행률 파일: B04 산출 폴더 아래에 원자적으로 기록/조회한다. PROGRESS_FILE_RELATIVE = ("B04_wf1_Surface", "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 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( connection: aiomysql.Connection, *, project_id: UUID, input_file_id: int, analysis_result: dict[str, Any], source_filters: list[str], ) -> list[int]: """WF1 분석 결과를 DB에 저장한다. 이 함수는 트랜잭션을 시작하거나 종료하지 않는다. 호출자는 같은 커넥션에서 begin/commit/rollback을 한 번만 수행해야 한다. """ input_file = await get_input_file(connection, project_id, input_file_id) processed = analysis_result["processed"] processed_cloud_id = await create_processed_point_cloud( connection, input_file_id=input_file_id, project_id=project_id, process_type="structured", processed_file_path=processed["processed_file_path"], converted_format=None, converted_file_path=processed["converted_file_path"], point_count=processed["point_count"], bounds=processed["bounds"], statistics=processed["statistics"], classification_summary=None, processing_params={"filters": source_filters}, ) surface_model_ids: list[int] = [] for model in analysis_result["models"]: model_id = await create_surface_model( connection, project_id=project_id, model_type=model["model_type"], source_file_id=input_file_id, processed_cloud_id=processed_cloud_id, crs_epsg=input_file["crs_epsg"], resolution_m=model["resolution_m"], model_file_path=model["model_file_path"], generation_params=model["generation_params"], ) surface_model_ids.append(model_id) for layer in model["layers"]: await create_terrain_layer( connection, surface_model_id=model_id, layer_name=layer["layer_name"], geometry_type=layer["geometry_type"], layer_file_path=layer["file_path"], file_format=layer["file_format"], file_size_mb=None, statistics=None, ) return surface_model_ids @router.post("/{project_id}/surface/analyze", response_model=SurfaceAnalyzeResponse) async def analyze_surface( project_id: UUID, request: SurfaceAnalyzeRequest ) -> SurfaceAnalyzeResponse | JSONResponse: """LAS 구조화·지면 필터·지표면 모델 생성을 실행하고 DB에 기록한다.""" try: source_filters = request.resolved_filters() methods = request.resolved_methods() except ValueError as exc: return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) pool = get_db_pool() try: params = { "input_file_id": request.input_file_id, "source_filters": source_filters, "methods": methods, "force": request.force, } async with pool.acquire() as connection: async with connection.cursor() as cursor: await start_stage(cursor, str(project_id), 1, params) await connection.commit() stored_path = await get_project_storage_relative_path(connection, project_id) project_root = Path(resolve_stored_project_path(stored_path)) input_file = await get_input_file(connection, project_id, request.input_file_id) las_path = project_root / Path(input_file["raw_file_path"]) if not las_path.is_file(): return JSONResponse( status_code=404, content={"status": "error", "message": "원본 LAS 파일을 찾을 수 없습니다."}, ) # 분석 시작 진행률 기록 (별도 스레드의 콜백은 파일에만 원자적 기록). write_surface_progress(project_root, 5, "analyzing", "WF1 분석을 시작합니다.") def _on_progress(percent: int, stage: str, message: str) -> None: write_surface_progress(project_root, percent, stage, message) # 무거운 지형 연산은 이벤트 루프를 막지 않도록 별도 스레드에서 실행. result = await asyncio.to_thread( run_surface_analysis, project_root, las_path, source_filters=source_filters, methods=methods, force=request.force, on_progress=_on_progress, ) # DB 기록 (트랜잭션) await connection.begin() try: surface_model_ids = await save_surface_analysis_to_db( connection, project_id=project_id, input_file_id=request.input_file_id, analysis_result=result, source_filters=source_filters, ) async with connection.cursor() as cursor: await complete_stage(cursor, str(project_id), 1) await connection.commit() except Exception: await connection.rollback() raise write_surface_progress(project_root, 100, "completed", "WF1 분석이 완료되었습니다.") return SurfaceAnalyzeResponse( project_id=str(project_id), ground_summary=result["ground_summary"], manifest_status=result["manifest"].get("status", "unknown"), surface_model_ids=surface_model_ids, ) except LookupError as exc: return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) except (OSError, ValueError) as exc: async with pool.acquire() as connection, connection.cursor() as cursor: await fail_stage(cursor, str(project_id), 1, str(exc)) await connection.commit() return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) except Exception as exc: logger.exception("B04 지표면 분석 실패: project_id=%s", project_id) async with pool.acquire() as connection, connection.cursor() as cursor: await fail_stage(cursor, str(project_id), 1, str(exc)) await connection.commit() return JSONResponse( status_code=500, content={"status": "error", "message": "지표면 분석 처리 중 오류가 발생했습니다."}, ) @router.get("/{project_id}/surface/models", response_model=SurfaceModelListResponse) async def get_surface_models(project_id: UUID) -> SurfaceModelListResponse | JSONResponse: """프로젝트의 지표면 모델 목록을 조회한다.""" pool = get_db_pool() try: async with pool.acquire() as connection: models = await list_surface_models(connection, project_id) return SurfaceModelListResponse( project_id=str(project_id), models=[SurfaceModelSummary(**model) for model in models], ) except Exception: logger.exception("B04 지표면 모델 목록 조회 실패: project_id=%s", project_id) return JSONResponse( status_code=500, content={"status": "error", "message": "모델 목록 조회 중 오류가 발생했습니다."}, ) @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 rgb_sample = None if "rgb" in structured: rgb_arr = np.asarray(structured["rgb"]) if rgb_arr.ndim > 0 and len(rgb_arr) == point_count: if point_count > POINT_CLOUD_SAMPLE_LIMIT: rgb_sample = rgb_arr[indexes] else: rgb_sample = rgb_arr 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(), rgb=rgb_sample.astype(int).tolist() if rgb_sample is not None else None, ) 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") 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 분석 대기 중입니다." 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( project_id: UUID, model_id: int, smooth: bool = False, ) -> FileResponse | 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" return FileResponse(preview_path, media_type=media_type, filename=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": "프리뷰 파일 조회 중 오류가 발생했습니다."}, ) @router.get("/{project_id}/surface/models/{model_id}/contour", response_model=None) async def get_surface_model_contour( project_id: UUID, model_id: int, interval: float = 5.0, smooth: bool = False, ) -> FileResponse | JSONResponse: """지표면 모델의 등고선 JSON 파일을 반환한다.""" 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 parts = stem.split("_") if len(parts) >= 2: method = parts[0] filter_key = "_".join(parts[1:]) else: method = model_type filter_key = "csf" if smooth and method in ("dtm", "tin"): contour_filename = f"contour_{filter_key}_{method}_smooth_{interval}m.json" else: contour_filename = f"contour_{filter_key}_{method}_{interval}m.json" contour_path = models_dir / contour_filename if not contour_path.is_file(): fallback_files = list(models_dir.glob(f"contour_{filter_key}_{method}*.json")) if smooth and method in ("dtm", "tin"): fallback_files = list( models_dir.glob(f"contour_{filter_key}_{method}_smooth_*.json") ) if fallback_files: contour_path = fallback_files[0] if not contour_path.is_file(): return JSONResponse( status_code=404, content={ "status": "error", "message": "등고선 파일이 생성되지 않았거나 존재하지 않습니다.", }, ) return FileResponse(contour_path, media_type="application/json", filename=contour_filename) except Exception: logger.exception( "지표면 모델 등고선 조회 실패: project_id=%s, model_id=%s", project_id, model_id ) return JSONResponse( status_code=500, content={"status": "error", "message": "등고선 파일 조회 중 오류가 발생했습니다."}, ) # VWorld 메타 API @router.get("/{project_id}/vworld-meta", response_model=None) async def get_vworld_meta( project_id: UUID, layer_name: str = "satellite" ) -> dict[str, Any] | JSONResponse: """VWorld 위성 맵 이미지 매핑 좌표 메타데이터를 반환합니다.""" 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)) target_dir = project_root / "B04_wf1_Surface" / "processed" target_layer = "white" if layer_name.lower() in ["gray", "white"] else layer_name.lower() meta_name = f"vworld_{target_layer}_meta.json" meta_path = target_dir / meta_name if not meta_path.exists() and target_layer == "satellite": meta_path = target_dir / "vworld_meta.json" if not meta_path.exists(): return JSONResponse( status_code=404, content={ "status": "error", "message": f"VWorld {layer_name} 메타데이터를 찾을 수 없습니다.", }, ) return json.loads(meta_path.read_text(encoding="utf-8")) except Exception as exc: return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)}) # VWorld 맵 API @router.get("/{project_id}/vworld-map", response_model=None) async def get_vworld_map( project_id: UUID, layer_name: str = "satellite" ) -> FileResponse | JSONResponse: """배경 지도 레이어 PNG 이미지를 반환합니다.""" 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)) target_dir = project_root / "B04_wf1_Surface" / "processed" target_layer = layer_name.lower() if target_layer in ["gray", "white"]: target_layer = "white" map_name = f"vworld_{target_layer}.png" map_path = target_dir / map_name if not map_path.exists() and target_layer == "satellite": map_path = target_dir / "vworld_map.png" if not map_path.exists(): return JSONResponse( status_code=404, content={ "status": "error", "message": f"VWorld {layer_name} 지도가 존재하지 않습니다.", }, ) return FileResponse(map_path, media_type="image/png") except Exception as exc: return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)}) # GeoJSON 조회 API @router.get("/{project_id}/geojson", response_model=None) async def get_project_geojson(project_id: UUID, layer: str) -> dict[str, Any] | JSONResponse: """저장된 프로젝트의 특정 GeoJSON 레이어 데이터를 반환합니다.""" 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)) target_dir = project_root / "B04_wf1_Surface" / "processed" layer_mapping = { "지적도": "연속지적도_bounds.geojson", "용도지역": "용도지역도_bounds.geojson", "행정구역_시군구": "행정구역_시군구_bounds.geojson", "행정구역_읍면동": "행정구역_읍면동_bounds.geojson", "수계망": "수계망_물줄기_bounds.geojson", "등고선": "등고선_bounds.geojson", "산사태": "산사태위험등급_bounds.geojson", } filename = layer_mapping.get(layer) if not filename: return JSONResponse( status_code=400, content={"status": "error", "message": "유효하지 않은 레이어명입니다."}, ) filepath = target_dir / filename if not filepath.exists(): return JSONResponse( status_code=404, content={ "status": "error", "message": f"요청한 레이어({layer}) 파일이 존재하지 않습니다.", }, ) if layer == "등고선": simplified_filepath = target_dir / "등고선_bounds_simplified.geojson" if simplified_filepath.exists(): try: return json.loads(simplified_filepath.read_text(encoding="utf-8")) except Exception: pass try: import geopandas as gpd gdf = gpd.read_file(filepath) gdf["geometry"] = gdf["geometry"].simplify( tolerance=0.00003, preserve_topology=True ) simplified_filepath.write_text(gdf.to_json(), encoding="utf-8") return json.loads(simplified_filepath.read_text(encoding="utf-8")) except Exception as e: logger.warning("등고선 단순화 처리 실패 (원본 전송): %s", e) return json.loads(filepath.read_text(encoding="utf-8")) except Exception as exc: return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)}) # 별도 tiles_router 정의 (prefix 없음) tiles_router = APIRouter(tags=["B04 MVT Tiles"]) @tiles_router.get("/tiles/{project_id}/{layer}/{z}/{x}/{y}.pbf", response_model=None) async def get_vector_tile(project_id: UUID, layer: str, z: int, x: int, y: int) -> Response: """프로젝트의 특정 레이어에 대한 정밀 벡터 타일(MVT) 조각을 동적으로 렌더링하여 반환합니다.""" 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)) target_dir = project_root / "B04_wf1_Surface" / "processed" layer_mapping = { "지적도": "연속지적도_bounds.geojson", "용도지역": "용도지역도_bounds.geojson", "행정구역_시군구": "행정구역_시군구_bounds.geojson", "행정구역_읍면동": "행정구역_읍면동_bounds.geojson", "수계망": "수계망_물줄기_bounds.geojson", "등고선": "등고선_bounds.geojson", "산사태": "산사태위험등급_bounds.geojson", "임도노선": "임도노선.geojson", } filename = layer_mapping.get(layer) if not filename: raise HTTPException(status_code=400, detail="유효하지 않은 레이어명입니다.") filepath = target_dir / filename if layer == "임도노선" and not filepath.exists(): from config.config_system import PROJECT_ROOT road_shp_candidates = list(PROJECT_ROOT.glob("samples/**/*_Polyline.shp")) if road_shp_candidates: try: import geopandas as gpd gdf = gpd.read_file(road_shp_candidates[0]) gdf = gdf.to_crs(epsg=4326) filepath.write_text(gdf.to_json(), encoding="utf-8") except Exception: pass if not filepath.exists(): import mapbox_vector_tile empty_tile = mapbox_vector_tile.encode([]) if mapbox_vector_tile else b"" return Response(content=empty_tile, media_type="application/x-protobuf") cache_key = f"{project_id}_{layer}" from B04_wf1_Surface.B04_wf1_Surface_Engine_MvtHelper import generate_mvt_tile mvt_bytes = generate_mvt_tile(filepath, cache_key, z, x, y, layer_name=layer) return Response( content=mvt_bytes, media_type="application/x-protobuf", headers={"Content-Encoding": "identity"}, ) except Exception: import mapbox_vector_tile empty_tile = mapbox_vector_tile.encode([]) if mapbox_vector_tile else b"" return Response(content=empty_tile, media_type="application/x-protobuf")