import asyncio import json import logging from pathlib import Path from typing import Any from uuid import UUID from fastapi import APIRouter, HTTPException, Request, Response from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from common_util.common_util_http_cache import cached_file_response from common_util.common_util_json import LONLAT_DIGITS, round_floats from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B04 Surface GIS"]) tiles_router = APIRouter(tags=["B04 MVT Tiles"]) # 수치지형도 도엽 병합 레이어 (SheetParser 산출과 1:1 — 정의는 SHEET_LAYERS가 유일) from B04_PreProcess.B04_PreProcess_Engine_SheetParser import SHEET_LAYERS # noqa: E402 _SHEET_GEOJSON_FILES = {f"도엽_{k}": f"도엽_{k}.geojson" for k in SHEET_LAYERS} # 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_PreProcess" / "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, request: Request, layer_name: str = "satellite" ) -> Response | JSONResponse: """배경 지도 레이어 PNG 이미지를 반환합니다(ETag — 바뀌지 않았으면 304).""" 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_PreProcess" / "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 cached_file_response(request, map_path, "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, request: Request ) -> dict[str, Any] | Response | JSONResponse: """저장된 프로젝트의 특정 GeoJSON 레이어 데이터를 반환합니다. 도엽 레이어는 분석용 원본 대신 **표시용 사본**(프로젝트 주변만 잘라 좌표를 줄인 것)을 파일 그대로 내보낸다. 원본을 매번 읽어 재직렬화하면 등고선 한 장에 2초가 든다. """ 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_PreProcess" / "processed" layer_mapping = { "지적도": "연속지적도_bounds.geojson", "용도지역": "용도지역도_bounds.geojson", "행정구역_시군구": "행정구역_시군구_bounds.geojson", "행정구역_읍면동": "행정구역_읍면동_bounds.geojson", "등고선": "등고선_bounds.geojson", **_SHEET_GEOJSON_FILES, } filename = layer_mapping.get(layer) if not filename: return JSONResponse( status_code=400, content={"status": "error", "message": "유효하지 않은 레이어명입니다."}, ) filepath = target_dir / filename # 등고선은 전처리 이후 국가 gpkg가 추가된 경우를 대비해 요청 시점에 크롭 생성한다. if layer == "등고선" and not filepath.exists(): from B04_PreProcess.B04_PreProcess_Engine_GisVector import ( crop_national_contours, read_bounds_wgs84_from_meta, ) bounds_wgs84 = read_bounds_wgs84_from_meta(target_dir) if bounds_wgs84 is not None: await asyncio.to_thread(crop_national_contours, bounds_wgs84, target_dir) if not filepath.exists(): return JSONResponse( status_code=404, content={ "status": "error", "message": f"요청한 레이어({layer}) 파일이 존재하지 않습니다.", }, ) if layer in _SHEET_GEOJSON_FILES: # 도엽 산출물은 자르거나 줄이지 않고 파일 그대로 보낸다(2026-08-01 사용자 지시). # 재직렬화만 건너뛰어도 요청당 2초가 사라지고, ETag로 두 번째부터는 304가 된다. # 다만 **자릿수만 줄인 사본**을 한 번 만들어 그것을 보낸다(2026-09-06) — 형상은 # 그대로고 뜻 없는 자리만 사라진다. return cached_file_response( request, await asyncio.to_thread(_display_copy, filepath), "application/geo+json", ) 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.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_PreProcess" / "processed" layer_mapping = { "지적도": "연속지적도_bounds.geojson", "용도지역": "용도지역도_bounds.geojson", "행정구역_시군구": "행정구역_시군구_bounds.geojson", "행정구역_읍면동": "행정구역_읍면동_bounds.geojson", "등고선": "등고선_bounds.geojson", "임도노선": "임도노선.geojson", **_SHEET_GEOJSON_FILES, } 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_PreProcess.B04_PreProcess_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") # 계획노선(B03 업로드 CSV) 폴리라인 조회 — 2D 지도에 계획선을 겹쳐 그리는 데 쓴다. @router.get("/{project_id}/planned-route", response_model=None) async def get_planned_route(project_id: UUID) -> dict[str, Any] | JSONResponse: """B03에 업로드된 계획노선을 사업지 좌표계(m) 점 목록으로 돌려준다. B05의 확정 경로가 아니라 **원청이 준 계획선**이다. 아직 올리지 않았거나 좌표가 모자라면 빈 목록을 돌려준다 — 화면은 계획선만 빼고 그대로 그린다. """ from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj from common_util.common_util_route_geometry import ( find_planned_route_file, read_planned_route, ) 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)) route_file = find_planned_route_file(project_root / "B03_FileInput" / "input") planned = read_planned_route(route_file) if route_file else None if planned is None or len(planned.vertices) < 2: return {"status": "success", "points": []} # 배경 지도 메타와 같은 좌표계로 맞춘다. 노선 파일이 제 좌표계를 적어 두었고 # 그것이 프로젝트 좌표계와 다르면 여기서 한 번 옮긴다. target_epsg = project_epsg_from_prj(project_root) points = [(float(vertex.x), float(vertex.y)) for vertex in planned.vertices] source_epsg = planned.crs_input or target_epsg if source_epsg != target_epsg: from pyproj import Transformer transformer = Transformer.from_crs(source_epsg, target_epsg, always_xy=True) points = [transformer.transform(x, y) for x, y in points] return {"status": "success", "points": [{"x": x, "y": y} for x, y in points]} except Exception as exc: logger.warning("계획노선 조회 실패: %s", exc) return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)}) # ── 도엽등고 서피스 보간 방식 (2026-09-01) ──────────────────────────────── # 자동 전처리는 기본 방식 하나만 만든다. 나머지는 관리자가 화면에서 그 방식을 # 고를 때 여기로 요청해 만든다. 파일명이 방식마다 갈려 있어(`dtm_sheet_{방식}.npz`) # 한 번 만든 방식은 다시 골라도 그대로 쓰인다. @router.get("/{project_id}/surface/sheet-methods", response_model=None) async def list_sheet_methods(project_id: UUID) -> dict[str, Any]: """고를 수 있는 도엽 보간 방식과, 이미 만들어 둔 방식을 알려준다.""" from B04_PreProcess.B04_PreProcess_Engine_SheetMethods import SHEET_METHOD_LABELS from config.config_system import SHEET_SURFACE_METHODS pool = get_db_pool() async with pool.acquire() as connection: stored_path = await get_project_storage_relative_path(connection, project_id) models_dir = Path(resolve_stored_project_path(stored_path)) / "B04_PreProcess" / "models" return { "status": "success", "methods": [ { "key": key, "label": SHEET_METHOD_LABELS.get(key, key), "built": (models_dir / f"dtm_sheet_{key}.npz").is_file(), } for key in SHEET_SURFACE_METHODS ], } @router.post("/{project_id}/surface/sheet-surface", response_model=None) async def build_sheet_surface(project_id: UUID, request: Request) -> dict[str, Any] | JSONResponse: """도엽 서피스 한 방식을 만들어 DB에 등록한다. 이미 있으면 그대로 둔다.""" from B04_PreProcess.B04_PreProcess_Engine_SheetMethods import SHEET_METHOD_BUILDERS from B04_PreProcess.B04_PreProcess_Engine_SheetSurface import build_sheet_surface_from_route from B04_PreProcess.B04_PreProcess_Repository import save_sheet_surface_models body = await request.json() method = str(body.get("method") or "") if method not in SHEET_METHOD_BUILDERS: return JSONResponse( status_code=400, content={"status": "error", "message": f"지원하지 않는 보간 방식입니다: {method}"}, ) pool = get_db_pool() 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)) stage_root = project_root / "B04_PreProcess" models = await asyncio.to_thread( build_sheet_surface_from_route, project_root, stage_root / "processed", stage_root / "models", [method], ) if not models: return JSONResponse( status_code=400, content={"status": "error", "message": "도엽 서피스를 만들지 못했습니다."}, ) async with pool.acquire() as connection: await connection.begin() try: model_ids = await save_sheet_surface_models(connection, project_id, models) await connection.commit() except Exception: await connection.rollback() raise return {"status": "success", "method": method, "surface_model_ids": model_ids} def _display_copy(source: Path) -> Path: """도엽 GeoJSON 의 **표시용 사본**(좌표 자릿수만 줄인 것) 경로를 돌려준다. 저장된 파일에는 `1.4000000000000001` 꼴로 뜻 없는 자리가 붙어 있다 — 위경도 7자리 (1.1cm)로 맞추면 형상은 그대로면서 등고선 도엽이 65.9MB → 36.8MB 가 된다(2026-09-06 실측). 압축까지 얹으면 11.3MB. 사본은 **한 번만** 만든다(66MB 기준 약 3초). 원본이 새로 깔리면 수정시각이 앞서므로 저절로 다시 만든다. 만들다 실패하면 원본을 그대로 보낸다 — 화면을 막지 않는다. """ target = source.with_suffix(".display.geojson") try: if target.exists() and target.stat().st_mtime >= source.stat().st_mtime: return target payload = json.loads(source.read_text(encoding="utf-8")) target.write_text( json.dumps( round_floats(payload, LONLAT_DIGITS), ensure_ascii=False, separators=(",", ":") ), encoding="utf-8", ) logger.info( "도엽 표시용 사본 생성: %s (%.1fMB → %.1fMB)", source.name, source.stat().st_size / 1e6, target.stat().st_size / 1e6, ) return target except Exception as exc: # 사본 실패가 화면을 막으면 안 된다. logger.warning("도엽 표시용 사본을 만들지 못해 원본을 보냅니다 (%s): %s", source.name, exc) return source