feat(B03): 계획노선 shapefile 입력과 PRJ 2개 분리를 지원한다

원청 정식 계획노선이 shapefile(UTM-K)로, 지형이 별도 PRJ(동부원점 Bessel)로
들어오는데 입력 경로가 shapefile 확장자를 막고 PRJ를 프로젝트당 1개로 전제했다.

- 업로드 허용에 .shp/.shx/.dbf/.cpg 추가, 한 번에 보낼 파일 수 5 -> 10
- B03_FileInput_Engine_Shapefile: ESRI 규격 직접 파싱(GDAL 미사용). 형제 파일이
  아직 안 왔어도 .shp 하나로 기하를 읽는다. .cpg 내용이 949뿐인 실물을 CP949로
  정규화해 한글 속성을 살린다.
- 노선 판독을 read_planned_route로 일원화(CSV/shapefile), PlannedRoute에
  crs_input 추가 - 변환 입력은 EPSG 코드가 아니라 crs_input_from_prj가 주는
  값(EPSG:n 또는 원문 WKT)이다. 실물 PRJ 2종 모두 to_epsg가 None이다.
- shapefile 세트를 input/shp/ 한 폴더에 모은다(GDAL 요건). 노선 PRJ가 그 안에
  남으므로 지형 PRJ(input/prj/)와 파일명 정렬 운에 기대지 않고 갈린다.
  find_project_prj가 지형 PRJ를 프로젝트 좌표계로 고른다.
- 필수 세트를 노선 1종(csv 또는 shp) + prj + tfw로 완화, shp면 shx/dbf 동반 필수.
- UI: 확장자 단독 슬롯 매칭을 basename 그룹핑으로 바꿔 노선 PRJ와 지형 PRJ가
  같은 슬롯을 다투지 않게 하고, 노선 슬롯이 파일 한 벌을 담아 함께 전송한다.

자체검증: tmp/tests/test_route_shapefile_input.py 9개 통과, tsc --noEmit 통과,
ruff check/format 통과. 전체 스위트 잔여 실패 11건은 HEAD 사본(git archive)에서
동일하게 재현되는 기존 실패다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 19:28:34 +09:00
co-authored by Claude Opus 5
parent 9bc722dfd6
commit fe72ea041b
18 changed files with 582 additions and 48 deletions
+9 -4
View File
@@ -279,11 +279,16 @@ def download_geodata(
실패해도 예외를 밖으로 던지지 않는다 — 분석 본체를 막지 않는다.
"""
try:
# 입력 파일과 같은 폴더의 .prj를 우선 사용 (PLAN E-1: 전체 재귀 glob 제거)
prj_candidates = sorted(prj_search_dir.glob("*.prj")) or sorted(
project_root.glob("B03_FileInput/**/*.prj")
# 입력 파일과 같은 폴더의 .prj를 우선 사용 (PLAN E-1: 전체 재귀 glob 제거).
# 없으면 지형 PRJ — 노선 shapefile 세트의 PRJ가 섞이지 않도록 고른다(2026-08-31).
from common_util.common_util_crs import find_project_prj
prj_candidates = sorted(prj_search_dir.glob("*.prj"))
prj_path = (
prj_candidates[0]
if prj_candidates
else (find_project_prj(project_root) or project_root / "result.prj")
)
prj_path = prj_candidates[0] if prj_candidates else project_root / "result.prj"
from B04_PreProcess.B04_PreProcess_Engine_Extent import (
download_extent,
@@ -144,10 +144,16 @@ def planned_route_bounds(project_root: Path, target_epsg: str) -> dict[str, floa
def project_epsg_from_prj(project_root: Path) -> str:
"""프로젝트 PRJ에서 좌표계를 읽는다. 없으면 중부원점(EPSG:5186)."""
"""프로젝트 PRJ에서 좌표계를 읽는다. 없으면 중부원점(EPSG:5186).
PRJ가 둘 이상 올라오므로(노선 세트 + 지형) 지형 PRJ를 고른다 — `find_project_prj`.
"""
from common_util.common_util_crs import find_project_prj
from .B04_PreProcess_Engine_VWorld import get_epsg_from_prj
for prj_path in sorted(project_root.glob("B03_FileInput/**/*.prj")):
prj_path = find_project_prj(project_root)
if prj_path is not None:
return get_epsg_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore"))
return "EPSG:5186"
@@ -451,14 +451,14 @@ def build_sheet_surface_from_route(
"""B03 업로드 계획노선 CSV를 찾아 방식별 도엽 서피스를 만든다. 없으면 빈 목록."""
from common_util.common_util_route_geometry import (
find_planned_route_file,
read_planned_route_csv,
read_planned_route,
)
route_file = find_planned_route_file(project_root / "B03_FileInput" / "input")
if route_file is None:
logger.warning("도엽 서피스: 계획 노선 파일이 없습니다.")
return []
planned = read_planned_route_csv(route_file)
planned = read_planned_route(route_file)
if planned is None or len(planned.vertices) < 2:
logger.warning("도엽 서피스: 계획 노선 파일을 읽지 못했습니다: %s", route_file.name)
return []
@@ -478,7 +478,7 @@ def run_sheet_surface_analysis(
반환 형식은 `run_surface_analysis()`와 같다(save_surface_analysis_to_db 호환).
"""
from common_util.common_util_route_geometry import read_planned_route_csv
from common_util.common_util_route_geometry import read_planned_route
def _report(percent: int, stage: str, message: str) -> None:
if on_progress is not None:
@@ -490,7 +490,7 @@ def run_sheet_surface_analysis(
processed_dir.mkdir(parents=True, exist_ok=True)
models_dir.mkdir(parents=True, exist_ok=True)
planned = read_planned_route_csv(route_csv_path)
planned = read_planned_route(route_csv_path)
if planned is None or len(planned.vertices) < 2:
raise ValueError(f"계획 노선 파일을 읽지 못했습니다: {route_csv_path.name}")
epsg = planned.epsg or 5186
+3 -3
View File
@@ -250,7 +250,7 @@ async def get_planned_route(project_id: UUID) -> dict[str, Any] | JSONResponse:
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_csv,
read_planned_route,
)
pool = get_db_pool()
@@ -259,7 +259,7 @@ async def get_planned_route(project_id: UUID) -> dict[str, Any] | JSONResponse:
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_csv(route_file) if route_file else None
planned = read_planned_route(route_file) if route_file else None
if planned is None or len(planned.vertices) < 2:
return {"status": "success", "points": []}
@@ -267,7 +267,7 @@ async def get_planned_route(project_id: UUID) -> dict[str, Any] | JSONResponse:
# 그것이 프로젝트 좌표계와 다르면 여기서 한 번 옮긴다.
target_epsg = project_epsg_from_prj(project_root)
points = [(float(vertex.x), float(vertex.y)) for vertex in planned.vertices]
source_epsg = f"EPSG:{planned.epsg}" if planned.epsg else target_epsg
source_epsg = planned.crs_input or target_epsg
if source_epsg != target_epsg:
from pyproj import Transformer