From fe72ea041b412b51aa75a3c643f0663b5be7d51a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 31 Aug 2026 19:28:34 +0900 Subject: [PATCH] =?UTF-8?q?feat(B03):=20=EA=B3=84=ED=9A=8D=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20shapefile=20=EC=9E=85=EB=A0=A5=EA=B3=BC=20PRJ=202?= =?UTF-8?q?=EA=B0=9C=20=EB=B6=84=EB=A6=AC=EB=A5=BC=20=EC=A7=80=EC=9B=90?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 원청 정식 계획노선이 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) --- B01_Dashboard/B01_Dashboard_UI_TempModal.ts | 4 +- B03_FileInput/B03_FileInput_Engine.py | 42 ++- B03_FileInput/B03_FileInput_Engine_Analyze.py | 4 + .../B03_FileInput_Engine_Shapefile.py | 244 ++++++++++++++++++ B03_FileInput/B03_FileInput_Repository.py | 15 +- .../B03_FileInput_Repository_Temp.py | 17 +- B03_FileInput/B03_FileInput_Router.py | 13 +- B03_FileInput/B03_FileInput_Service_Chain.py | 6 +- B03_FileInput/B03_FileInput_UI_Page.ts | 46 +++- B03_FileInput/B03_FileInput_UI_Support.ts | 46 +++- B04_PreProcess/B04_PreProcess_Engine.py | 13 +- .../B04_PreProcess_Engine_Extent.py | 10 +- .../B04_PreProcess_Engine_SheetSurface.py | 8 +- B04_PreProcess/B04_PreProcess_Router_GIS.py | 6 +- common_util/common_util_crs.py | 16 ++ common_util/common_util_route_geometry.py | 102 +++++++- config/config_frontend.ts | 17 +- config/config_system.py | 21 +- 18 files changed, 582 insertions(+), 48 deletions(-) create mode 100644 B03_FileInput/B03_FileInput_Engine_Shapefile.py diff --git a/B01_Dashboard/B01_Dashboard_UI_TempModal.ts b/B01_Dashboard/B01_Dashboard_UI_TempModal.ts index 4700f358..e5a55b41 100644 --- a/B01_Dashboard/B01_Dashboard_UI_TempModal.ts +++ b/B01_Dashboard/B01_Dashboard_UI_TempModal.ts @@ -14,7 +14,7 @@ import { createButton, createInputField, showToast } from "@ui/ui_template_eleme import { table, text } from "@ui/ui_template_general_blocks"; import { L } from "./B01_Dashboard_UI_Common"; -/** 파일 확장자 = 보관함 슬롯 종류(csv·las·prj·tfw·tif). */ +/** 파일 확장자 = 보관함 슬롯 종류(csv·shp 세트·las·prj·tfw·tif). */ export function tempFileType(fileName: string): string { const index = fileName.lastIndexOf("."); return index >= 0 ? fileName.slice(index + 1).toLowerCase() : ""; @@ -54,7 +54,7 @@ export function openTempFileModal(options: TempModalOptions): void { const picker = document.createElement("input"); picker.type = "file"; picker.multiple = true; - picker.accept = ".csv,.las,.laz,.tif,.tfw,.prj"; + picker.accept = ".csv,.shp,.shx,.dbf,.cpg,.las,.laz,.tif,.tfw,.prj"; picker.className = "b01-temp__hidden-input"; const pickRow = document.createElement("div"); diff --git a/B03_FileInput/B03_FileInput_Engine.py b/B03_FileInput/B03_FileInput_Engine.py index ae895ae4..1bea4337 100644 --- a/B03_FileInput/B03_FileInput_Engine.py +++ b/B03_FileInput/B03_FileInput_Engine.py @@ -11,6 +11,34 @@ from B03_FileInput.B03_FileInput_Schema import FileUploadDescriptor from common_util.common_util_storage import get_project_stage_path from config.config_system import CHUNK_TEMP_DIR, UPLOAD_CHUNK_SIZE_BYTES, UPLOAD_MAX_MB +# shapefile은 형제 파일이 **같은 폴더·같은 basename**이라야 열린다. 확장자별 폴더에 +# 흩어 두면 노선 자체를 못 읽는다. 그래서 세트는 `input/shp/`에 통째로 모은다. +# 부수 효과가 하나 더 있다 — 노선 PRJ가 이 폴더 안에 남으므로 지형 PRJ(`input/prj/`)와 +# 파일명 정렬 운에 기대지 않고 갈린다 (2026-08-31). +_SHAPEFILE_FOLDER = "shp" +_SHAPEFILE_OWNED_EXT = frozenset({"shp", "shx", "dbf", "cpg"}) + + +def reconcile_shapefile_members(stage_root: Path) -> list[Path]: + """먼저 도착해 다른 폴더에 앉은 shapefile 형제 파일을 `input/shp/`로 모은다. + + 파일이 오는 순서를 강제할 수 없다 — PRJ가 SHP보다 먼저 오면 확장자 규칙만으로는 + `input/prj/`에 앉는다. SHP가 들어온 시점에 같은 basename의 형제를 끌어온다. + """ + input_root = stage_root / "input" + shapefile_dir = input_root / _SHAPEFILE_FOLDER + if not shapefile_dir.exists(): + return [] + moved: list[Path] = [] + for shape_path in shapefile_dir.glob("*.shp"): + for extension in ("shx", "dbf", "cpg", "prj"): + stray = input_root / extension / f"{shape_path.stem}.{extension}" + target = shapefile_dir / stray.name + if stray.exists() and not target.exists(): + shutil.move(str(stray), str(target)) + moved.append(target) + return moved + def resolve_upload_destination( project_root: str | Path, @@ -18,8 +46,18 @@ def resolve_upload_destination( ) -> Path: """검증된 파일의 B03 입력 저장 경로를 생성해 반환한다.""" stage_root = Path(get_project_stage_path(str(project_root), "B03_FileInput")).resolve() - file_type = Path(descriptor.original_filename).suffix.lower().lstrip(".") - destination = (stage_root / "input" / file_type / descriptor.original_filename).resolve() + reconcile_shapefile_members(stage_root) + + source_name = Path(descriptor.original_filename) + file_type = source_name.suffix.lower().lstrip(".") + folder = file_type + if file_type in _SHAPEFILE_OWNED_EXT: + folder = _SHAPEFILE_FOLDER + elif file_type == "prj": + # 노선 shapefile의 짝 PRJ만 세트 폴더로. 지형 PRJ는 `input/prj/`에 남는다. + if (stage_root / "input" / _SHAPEFILE_FOLDER / f"{source_name.stem}.shp").exists(): + folder = _SHAPEFILE_FOLDER + destination = (stage_root / "input" / folder / descriptor.original_filename).resolve() if os.path.commonpath((stage_root, destination)) != str(stage_root): raise ValueError("업로드 저장 경로가 B03 단계 폴더를 벗어났습니다.") diff --git a/B03_FileInput/B03_FileInput_Engine_Analyze.py b/B03_FileInput/B03_FileInput_Engine_Analyze.py index 23fc4b29..50319d5f 100644 --- a/B03_FileInput/B03_FileInput_Engine_Analyze.py +++ b/B03_FileInput/B03_FileInput_Engine_Analyze.py @@ -396,6 +396,10 @@ def analyze_input_metadata(path: str | Path) -> dict[str, Any]: extension = source.suffix.lower() if extension == ".csv": return analyze_planned_route_csv(source) + if extension == ".shp": + from B03_FileInput.B03_FileInput_Engine_Shapefile import analyze_shapefile_metadata + + return analyze_shapefile_metadata(source) if extension in {".las", ".laz"}: return analyze_las_metadata(source) if extension == ".prj": diff --git a/B03_FileInput/B03_FileInput_Engine_Shapefile.py b/B03_FileInput/B03_FileInput_Engine_Shapefile.py new file mode 100644 index 00000000..aeb56ce8 --- /dev/null +++ b/B03_FileInput/B03_FileInput_Engine_Shapefile.py @@ -0,0 +1,244 @@ +"""계획노선 shapefile(.shp) 판독 — 기하·속성·좌표계. + +GDAL/geopandas를 쓰지 않고 ESRI Shapefile 규격을 직접 읽는다. 업로드 **직후** +메타데이터를 내야 하는데 그 시점엔 형제 파일(.shx/.dbf/.prj)이 아직 다 도착하지 +않았을 수 있고, GDAL은 한 짝이라도 비면 열기 자체를 실패하기 때문이다. 규격 파싱은 +.shp 하나만으로 기하를 읽어 낸다. + +좌표계는 EPSG 코드로 가리지 않는다 — 짝 PRJ 원문을 `crs_input_from_prj()`에 넘겨 +"EPSG:n" 또는 **원문 WKT**를 그대로 변환기 입력으로 쓴다(2026-08-31 사용자 확정). +""" + +import logging +import struct +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +_HEADER_BYTES = 100 +_FILE_CODE = 9994 + +# 규격상 폴리라인 계열만 계획노선으로 받는다. Z/M 변형도 XY는 같은 자리에 있다. +_POLYLINE_TYPES = {3: "PolyLine", 13: "PolyLineZ", 23: "PolyLineM"} +_SHAPE_TYPE_NAMES = { + 0: "Null", + 1: "Point", + 3: "PolyLine", + 5: "Polygon", + 8: "MultiPoint", + 11: "PointZ", + 13: "PolyLineZ", + 15: "PolygonZ", + 18: "MultiPointZ", + 21: "PointM", + 23: "PolyLineM", + 25: "PolygonM", + 28: "MultiPointM", + 31: "MultiPatch", +} + + +def _normalize_codepage(text: str) -> str | None: + """.cpg 내용을 파이썬 인코딩 이름으로 바꾼다. + + 실물은 `949` 한 줄만 들어 있다(2026-08-31 원청 자료) — `CP949`도 `EUC-KR`도 + 아니라서 그대로 넘기면 LookupError가 난다. + """ + value = (text or "").strip() + if not value: + return None + if value.isdigit(): + return f"cp{value}" + upper = value.upper().replace("-", "").replace("_", "") + if upper in {"ANSI", "OEM", "SYSTEM"}: + return "cp949" + return value + + +def shapefile_encoding(path: Path) -> str: + """짝 .cpg에 적힌 인코딩. 없으면 국내 자료 관례대로 CP949.""" + cpg_path = path.with_suffix(".cpg") + if cpg_path.exists(): + try: + encoding = _normalize_codepage(cpg_path.read_text(encoding="ascii", errors="ignore")) + if encoding: + "".encode(encoding) # 이름이 실재하는지 확인 — 없으면 LookupError + return encoding + except (OSError, LookupError): + logger.warning("shapefile .cpg 인코딩을 해석하지 못했습니다: %s", cpg_path.name) + return "cp949" + + +def shapefile_crs_input(path: Path) -> str | None: + """짝 .prj를 `Transformer.from_crs` 입력 문자열로 정규화해 돌려준다.""" + prj_path = path.with_suffix(".prj") + if not prj_path.exists(): + return None + from common_util.common_util_crs import crs_input_from_prj + + return crs_input_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore")) + + +def shapefile_epsg_label(path: Path) -> int | None: + """짝 .prj의 EPSG **라벨**. 변환에는 쓰지 않는다 — 메타 표시·로그용.""" + prj_path = path.with_suffix(".prj") + if not prj_path.exists(): + return None + from pyproj import CRS + + from common_util.common_util_crs import identify_epsg + + text = prj_path.read_text(encoding="utf-8", errors="ignore") + try: + return identify_epsg(CRS.from_wkt(text), text) + except Exception: # pragma: no cover — WKT 불량은 라벨 없음으로 흘린다 + return None + + +def _read_header(blob: bytes) -> dict[str, Any]: + if len(blob) < _HEADER_BYTES: + raise ValueError("shapefile 헤더가 100바이트에 못 미칩니다.") + file_code = struct.unpack(">i", blob[0:4])[0] + if file_code != _FILE_CODE: + raise ValueError("shapefile 파일 코드가 규격(9994)과 다릅니다.") + declared_bytes = struct.unpack(">i", blob[24:28])[0] * 2 + shape_type = struct.unpack(" list[list[tuple]]: + """폴리라인 레코드 하나를 파트별 정점 목록으로 푼다.""" + record_type = struct.unpack(" list[list[tuple]]: + """.shp의 모든 폴리라인 파트를 (x, y, z) 정점 목록으로 읽는다.""" + source = Path(path) + blob = source.read_bytes() + header = _read_header(blob) + if header["shape_type"] not in _POLYLINE_TYPES and header["shape_type"] != 0: + raise ValueError( + f"계획노선 shapefile은 폴리라인이어야 합니다(받은 형상: {header['shape_type_name']})." + ) + + limit = min(len(blob), header["declared_bytes"] or len(blob)) + parts: list[list[tuple]] = [] + offset = _HEADER_BYTES + while offset + 8 <= limit: + content_bytes = struct.unpack(">i", blob[offset + 4 : offset + 8])[0] * 2 + if content_bytes <= 0: + break + parts.extend(_read_polyline_record(blob, offset + 8, content_bytes)) + offset += 8 + content_bytes + return [part for part in parts if len(part) >= 2] + + +def read_shapefile_attributes(path: str | Path) -> dict[str, str]: + """짝 .dbf 첫 레코드의 속성. 없거나 못 읽으면 빈 dict.""" + dbf_path = Path(path).with_suffix(".dbf") + if not dbf_path.exists(): + return {} + encoding = shapefile_encoding(Path(path)) + try: + blob = dbf_path.read_bytes() + header_bytes, record_bytes = struct.unpack("<2H", blob[8:12]) + fields: list[tuple[str, int]] = [] + cursor = 32 + while cursor < header_bytes - 1 and blob[cursor] != 0x0D: + descriptor = blob[cursor : cursor + 32] + name = descriptor[0:11].split(b"\x00")[0].decode(encoding, errors="replace").strip() + fields.append((name, descriptor[16])) + cursor += 32 + record = blob[header_bytes : header_bytes + record_bytes] + if not record: + return {} + values: dict[str, str] = {} + position = 1 # 첫 바이트는 삭제 표시 + for name, width in fields: + raw = record[position : position + width] + values[name] = raw.decode(encoding, errors="replace").strip() + position += width + return values + except (OSError, struct.error, ValueError): + logger.warning("shapefile .dbf 속성을 읽지 못했습니다: %s", dbf_path.name) + return {} + + +def _route_name_from_attributes(attributes: dict[str, str], fallback: str) -> str: + for key in ("대상지", "노선명", "route_name", "NAME", "name"): + value = attributes.get(key) + if value: + return value + return fallback + + +def analyze_shapefile_metadata(path: str | Path) -> dict[str, Any]: + """계획노선 shapefile의 B03 메타데이터를 만든다.""" + source = Path(path) + header = _read_header(source.read_bytes()[:_HEADER_BYTES]) + parts = read_shapefile_parts(source) + attributes = read_shapefile_attributes(source) + point_count = sum(len(part) for part in parts) + missing = [ + extension for extension in (".shx", ".dbf") if not source.with_suffix(extension).exists() + ] + + return { + "file": source.name, + "extension": "shp", + "size_bytes": source.stat().st_size, + "purpose": "planned_route", + "route_name": _route_name_from_attributes(attributes, source.stem), + "shape_type": header["shape_type_name"], + "part_count": len(parts), + "point_count": point_count, + "epsg": shapefile_epsg_label(source), + "crs_input": shapefile_crs_input(source), + "encoding": shapefile_encoding(source), + "attributes": attributes, + "missing_members": missing, + "bounds": header["bounds"], + "start_point": list(parts[0][0]) if parts else None, + "end_point": list(parts[-1][-1]) if parts else None, + } diff --git a/B03_FileInput/B03_FileInput_Repository.py b/B03_FileInput/B03_FileInput_Repository.py index a35eab16..5460975b 100644 --- a/B03_FileInput/B03_FileInput_Repository.py +++ b/B03_FileInput/B03_FileInput_Repository.py @@ -65,7 +65,11 @@ async def get_project_input_readiness( connection: aiomysql.Connection, project_id: UUID, ) -> tuple[set[str], int | None, int | None]: - """업로드 파일 유형, 최신 포인트클라우드 입력 ID, 최신 계획노선 CSV 입력 ID를 반환한다.""" + """업로드 파일 유형, 최신 포인트클라우드 입력 ID, 최신 계획노선 입력 ID를 반환한다. + + 계획노선은 CSV 또는 shapefile이다. 둘 다 있으면 shapefile을 고른다 — + `find_planned_route_file()`의 우선순위와 같아야 WF1 입력과 실제 판독 대상이 갈리지 않는다. + """ async with connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( """ @@ -83,12 +87,15 @@ async def get_project_input_readiness( (int(row["id"]) for row in rows if str(row.get("file_type") or "") in {"las", "laz"}), None, ) - # LAS 없는 설계(2026-08-30)의 WF1 입력 — 계획노선 CSV가 분석 원천이 된다. - route_csv_id = next( + # LAS 없는 설계(2026-08-30)의 WF1 입력 — 계획노선 파일이 분석 원천이 된다. + route_id = next( + (int(row["id"]) for row in rows if str(row.get("file_type") or "") == "shp"), + None, + ) or next( (int(row["id"]) for row in rows if str(row.get("file_type") or "") == "csv"), None, ) - return file_types, point_cloud_id, route_csv_id + return file_types, point_cloud_id, route_id async def get_project_storage_relative_path( diff --git a/B03_FileInput/B03_FileInput_Repository_Temp.py b/B03_FileInput/B03_FileInput_Repository_Temp.py index eda9942b..4a7c57f0 100644 --- a/B03_FileInput/B03_FileInput_Repository_Temp.py +++ b/B03_FileInput/B03_FileInput_Repository_Temp.py @@ -12,15 +12,22 @@ import aiomysql from config.config_system import TEMP_UPLOAD_RETENTION_DAYS # 묶음이 "완료"로 넘어가려면 있어야 하는 파일 종류. B03 필수 슬롯과 같은 기준이다. -REQUIRED_TEMP_FILE_TYPES = frozenset({"csv", "prj", "tfw"}) +REQUIRED_TEMP_FILE_TYPES = frozenset({"prj", "tfw"}) POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"}) +# 계획노선은 CSV 또는 shapefile 중 하나 (2026-08-31). +ROUTE_FILE_TYPES = frozenset({"csv", "shp"}) +SHAPEFILE_REQUIRED_TYPES = frozenset({"shx", "dbf"}) def is_batch_required_complete(file_types: set[str]) -> bool: - """필수 파일(csv·prj·tfw + las/laz 1종)이 모두 찼는지.""" - return REQUIRED_TEMP_FILE_TYPES.issubset(file_types) and bool( - file_types & POINT_CLOUD_FILE_TYPES - ) + """필수 파일(계획노선 1종 + prj·tfw + las/laz 1종)이 모두 찼는지.""" + if not REQUIRED_TEMP_FILE_TYPES.issubset(file_types): + return False + if not file_types & ROUTE_FILE_TYPES: + return False + if "shp" in file_types and not SHAPEFILE_REQUIRED_TYPES.issubset(file_types): + return False + return bool(file_types & POINT_CLOUD_FILE_TYPES) async def create_temp_batch( diff --git a/B03_FileInput/B03_FileInput_Router.py b/B03_FileInput/B03_FileInput_Router.py index 5349a403..33df2550 100644 --- a/B03_FileInput/B03_FileInput_Router.py +++ b/B03_FileInput/B03_FileInput_Router.py @@ -76,8 +76,12 @@ _ANALYSIS_RUNNING_MESSAGE = "이 프로젝트는 지금 분석 중입니다. 끝 logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B03 File Input"]) -_REQUIRED_FILE_TYPES = frozenset({"csv", "prj", "tfw"}) +_REQUIRED_FILE_TYPES = frozenset({"prj", "tfw"}) _POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"}) +# 계획노선은 CSV 또는 shapefile 중 하나면 된다 (2026-08-31 — 원청 정식 노선이 shapefile). +_ROUTE_FILE_TYPES = frozenset({"csv", "shp"}) +# shapefile은 이 셋이 다 있어야 열린다. .cpg는 없으면 CP949로 읽으므로 필수가 아니다. +_SHAPEFILE_REQUIRED_TYPES = frozenset({"shx", "dbf"}) def _total_chunks(size_bytes: int, chunk_size_bytes: int) -> int: @@ -95,6 +99,11 @@ def _is_point_cloud_result(result: UploadedFileResult) -> bool: def _missing_required_file_types(file_types: set[str], las_free: bool = False) -> list[str]: missing = sorted(_REQUIRED_FILE_TYPES - file_types) + if not file_types.intersection(_ROUTE_FILE_TYPES): + missing.append("csv/shp") + # shapefile로 왔으면 형제 파일이 다 있어야 노선을 읽는다. + if "shp" in file_types: + missing.extend(sorted(_SHAPEFILE_REQUIRED_TYPES - file_types)) # LAS 없는 설계(도엽등고선 기반, 2026-08-30)는 LAS 필수를 면제한다. if not las_free and not file_types.intersection(_POINT_CLOUD_FILE_TYPES): missing.append("las/laz") @@ -164,7 +173,7 @@ async def _complete_file_input_if_ready( if not las_free: raise ValueError("LAS 또는 LAZ 입력 파일을 찾을 수 없습니다.") if route_csv_input_id is None: - raise ValueError("계획 노선 CSV 입력 파일을 찾을 수 없습니다.") + raise ValueError("계획 노선 입력 파일(CSV 또는 shapefile)을 찾을 수 없습니다.") # 자료가 갈렸으니 옛 계산 결과(파일 + DB)를 지우고 진행 표시도 되돌린다. 남겨 두면 # 아직 다시 만들어지지 않은 뒷단계 화면이 옛 결과를 새 자료 것인 양 보여준다. stored_path = await get_project_storage_relative_path(connection, project_id) diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index b7b69883..22149448 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -34,16 +34,16 @@ def _planned_route_points_in_project_crs(project_root: Path) -> list[dict[str, f 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, ) 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 None 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.upper() != target_epsg.upper(): from pyproj import Transformer diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index a9372e94..ea8877dd 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -44,6 +44,7 @@ import { getExtension, initializeSlots, makeSessionKey, + splitShapefileSelection, type FileSlot, type FileSlotState, type StoredUploadSession, @@ -289,6 +290,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { async function assignFileToSlot( file: File, targetSlot?: FileSlot, + companions: File[] = [], ): Promise { const extension = getExtension(file.name); const state = targetSlot @@ -323,6 +325,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { } state.file = file; + state.companions = companions.length ? companions : undefined; state.uploadSessionId = undefined; state.uploadStatus = "pending"; state.progressBytes = 0; @@ -353,8 +356,12 @@ export async function renderB03FileInput(root: HTMLElement): Promise { // 개수는 "고른 파일 수"가 아니라 **최종적으로 차는 슬롯 수**로 센다. // 같은 슬롯을 다시 고르는 것은 교체라 개수가 늘지 않는다 — 더하기로 세면 5개를 고른 // 뒤 파일 선택 영역으로 하나만 바꾸려 해도 초과로 막힌다(2026-08-08). + // 계획노선 shapefile은 파일 한 벌이 슬롯 하나로 간다 — 확장자만 보면 노선 PRJ가 + // 지형 PRJ 슬롯을 덮어쓴다(2026-08-31). + const { shapefile, rest } = splitShapefileSelection(files); const occupied = new Set(selectedStates().map((state) => state.slot)); - for (const file of files) { + if (shapefile) occupied.add(targetSlot ?? "csv"); + for (const file of rest) { const extension = getExtension(file.name); const slot = targetSlot ?? @@ -369,7 +376,14 @@ export async function renderB03FileInput(root: HTMLElement): Promise { } pageError.textContent = blocked ? L("B03_File_Error_LasFreeBlocked") : ""; void (async () => { - for (const file of files) await assignFileToSlot(file, targetSlot); + if (shapefile) { + await assignFileToSlot( + shapefile.primary, + targetSlot, + shapefile.companions, + ); + } + for (const file of rest) await assignFileToSlot(file, targetSlot); await detectPausedUploads(); })(); } @@ -381,6 +395,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { localStorage.removeItem(makeSessionKey(activeProjectId, state.file)); } state.file = undefined; + state.companions = undefined; state.uploadSessionId = undefined; state.uploadStatus = "pending"; state.progressBytes = 0; @@ -665,13 +680,32 @@ export async function renderB03FileInput(root: HTMLElement): Promise { setUploading(true); const uploaded: UploadedFileResult[] = []; try { - for (let index = 0; index < targetStates.length; index += 1) { - const state = targetStates[index]; + // 슬롯 하나가 파일 여럿일 수 있다(계획노선 shapefile 세트) — 완료 신호는 **마지막 + // 파일 한 건**에만 붙어야 하므로 슬롯이 아니라 파일 단위로 펼쳐서 센다. + const jobs: { state: FileSlotState; file: File }[] = []; + for (const state of targetStates) { + for (const companion of state.companions ?? []) + jobs.push({ state, file: companion }); + if (state.file) jobs.push({ state, file: state.file }); + } + for (let index = 0; index < jobs.length; index += 1) { + const { state, file } = jobs[index]; + // 동반 파일은 진행률·세션을 대표 파일과 섞지 않도록 임시 상태로 올린다. + const uploadState = + file === state.file + ? state + : { + ...state, + file, + companions: undefined, + uploadSessionId: undefined, + progressBytes: 0, + }; uploaded.push( ...(await uploadOneFile( activeProjectId, - state, - index === targetStates.length - 1, + uploadState, + index === jobs.length - 1, () => renderSlot(state.slot), lasFreeDesign, )), diff --git a/B03_FileInput/B03_FileInput_UI_Support.ts b/B03_FileInput/B03_FileInput_UI_Support.ts index 97ea273a..d601a250 100644 --- a/B03_FileInput/B03_FileInput_UI_Support.ts +++ b/B03_FileInput/B03_FileInput_UI_Support.ts @@ -13,6 +13,12 @@ export interface SlotConfig { export interface FileSlotState extends SlotConfig { file?: File; + /** + * 계획노선 shapefile의 동반 파일(.shx/.dbf/.cpg/.prj). 슬롯 하나가 파일 한 벌을 + * 받는 유일한 경우다 — GDAL이 열려면 형제 파일이 같이 있어야 한다(2026-08-31). + * 대표 파일(`file`)은 언제나 .shp이고, 동반 파일은 그보다 먼저 전송한다. + */ + companions?: File[]; uploadSessionId?: string; uploadStatus: UploadStatus; progressBytes: number; @@ -45,7 +51,7 @@ const SLOT_CONFIGS: readonly SlotConfig[] = [ slot: "csv", labelKey: "B03_File_Slot_PlannedRoute", icon: "⌁", - extensions: [".csv"], + extensions: [".csv", ".shp"], isRequired: true, }, { @@ -83,6 +89,44 @@ export function getExtension(fileName: string): string { return index >= 0 ? fileName.slice(index).toLowerCase() : ""; } +export function getBaseName(fileName: string): string { + const index = fileName.lastIndexOf("."); + return index >= 0 ? fileName.slice(0, index) : fileName; +} + +/** shapefile 동반 파일. `.prj`가 여기 들어 있어 지형 PRJ와 basename으로 갈린다. */ +const SHAPEFILE_COMPANION_EXT = [".shx", ".dbf", ".cpg", ".prj"]; + +/** + * 고른 파일을 「계획노선 shapefile 한 벌」과 나머지로 가른다. + * + * 확장자만 보고 슬롯을 찾으면 노선 PRJ와 지형 PRJ가 같은 슬롯을 다툰다. `.shp`와 + * **basename이 같은** 것만 노선 세트로 묶고, 남은 `.prj`는 지형 슬롯으로 보낸다. + */ +export function splitShapefileSelection(files: readonly File[]): { + shapefile: { primary: File; companions: File[] } | null; + rest: File[]; +} { + const primary = files.find((file) => getExtension(file.name) === ".shp"); + if (!primary) return { shapefile: null, rest: [...files] }; + const stem = getBaseName(primary.name); + const companions: File[] = []; + const rest: File[] = []; + for (const file of files) { + if (file === primary) continue; + const extension = getExtension(file.name); + if ( + SHAPEFILE_COMPANION_EXT.includes(extension) && + getBaseName(file.name) === stem + ) { + companions.push(file); + } else { + rest.push(file); + } + } + return { shapefile: { primary, companions }, rest }; +} + export function formatBytes(bytes: number): string { const gb = bytes / 1024 / 1024 / 1024; if (gb >= 1) return `${gb.toFixed(2)} GB`; diff --git a/B04_PreProcess/B04_PreProcess_Engine.py b/B04_PreProcess/B04_PreProcess_Engine.py index 3789d2df..d8d75139 100644 --- a/B04_PreProcess/B04_PreProcess_Engine.py +++ b/B04_PreProcess/B04_PreProcess_Engine.py @@ -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, diff --git a/B04_PreProcess/B04_PreProcess_Engine_Extent.py b/B04_PreProcess/B04_PreProcess_Engine_Extent.py index 96dafc18..2e5e931e 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Extent.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Extent.py @@ -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" diff --git a/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py b/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py index c4cb0df1..d2d0ee19 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py +++ b/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py @@ -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 diff --git a/B04_PreProcess/B04_PreProcess_Router_GIS.py b/B04_PreProcess/B04_PreProcess_Router_GIS.py index c93a9a26..c814cf92 100644 --- a/B04_PreProcess/B04_PreProcess_Router_GIS.py +++ b/B04_PreProcess/B04_PreProcess_Router_GIS.py @@ -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 diff --git a/common_util/common_util_crs.py b/common_util/common_util_crs.py index 6717c9f4..35ff8b5b 100644 --- a/common_util/common_util_crs.py +++ b/common_util/common_util_crs.py @@ -10,6 +10,7 @@ from __future__ import annotations import logging import re +from pathlib import Path from pyproj import CRS @@ -123,6 +124,21 @@ def identify_epsg(crs: CRS, wkt_text: str | None = None) -> int | None: return None +def find_project_prj(project_root: Path) -> Path | None: + """프로젝트 **작업 좌표계**를 정하는 PRJ를 고른다. + + 자료가 둘 이상의 좌표계로 들어온다(실측 2026-08-31 — 노선 shapefile은 UTM-K, + 지형은 동부원점 Bessel). 서피스 격자가 모델좌표의 주인이므로 지형 PRJ가 우선이고, + 노선 PRJ는 shapefile 세트 폴더(`input/shp/`) 안에 있어 여기서 섞이지 않는다. + 파일명 정렬 순서에 기대던 옛 `glob()[0]`을 대신한다. + """ + terrain = sorted(project_root.glob("B03_FileInput/input/prj/*.prj")) + if terrain: + return terrain[0] + remainder = sorted(project_root.glob("B03_FileInput/**/*.prj")) + return remainder[0] if remainder else None + + def crs_input_from_prj(prj_text: str) -> str | None: """PRJ 텍스트를 `Transformer.from_crs` 입력 문자열로 정규화한다. diff --git a/common_util/common_util_route_geometry.py b/common_util/common_util_route_geometry.py index 22913bcd..a47f763b 100644 --- a/common_util/common_util_route_geometry.py +++ b/common_util/common_util_route_geometry.py @@ -4,7 +4,7 @@ 정의한다. 어느 한쪽 페이지 폴더에 두면 반대 방향 import가 생긴다. 노선 원천은 두 가지다. - · B03에 업로드된 **계획 노선 파일**(CSV) — 배수유역 분석의 입력 + · B03에 업로드된 **계획 노선 파일**(CSV·shapefile) — 배수유역 분석의 입력 · DB `route_points` — B05에서 탐색·확정한 노선 둘 다 같은 `RouteVertex` 목록으로 바꿔 아래 함수들이 그대로 받는다. """ @@ -14,6 +14,7 @@ from __future__ import annotations import csv import logging import math +import struct from dataclasses import dataclass from pathlib import Path from typing import Any @@ -60,6 +61,10 @@ class PlannedRoute: epsg: int | None name: str | None source: Path + # `Transformer.from_crs` 입력 문자열("EPSG:n" 또는 **원문 WKT**). 좌표계를 EPSG 코드로 + # 가리면 라벨이 안 붙는 PRJ가 통째로 막히므로, 변환은 언제나 이 값으로 한다. + # `epsg`는 표시·로그용 라벨일 뿐이다 (2026-08-31 사용자 확정). + crs_input: str | None = None @property def line(self) -> LineString: @@ -109,17 +114,100 @@ def read_planned_route_csv(path: Path) -> PlannedRoute | None: logger.info( "계획 노선 %s: 정점 %d개, 연장 %.0fm, EPSG %s", path.name, len(vertices), cumulative, epsg ) - return PlannedRoute(vertices=vertices, epsg=epsg, name=name, source=path) + return PlannedRoute( + vertices=vertices, + epsg=epsg, + name=name, + source=path, + crs_input=f"EPSG:{epsg}" if epsg else None, + ) + + +def read_planned_route_shapefile(path: Path) -> PlannedRoute | None: + """계획 노선 shapefile을 읽어 정점 목록으로 바꾼다. + + 원청 자료는 **2차원 폴리라인**이라 z가 없다. 지반고는 확정 서피스에서 뽑으므로 + (`build_surface_sampler`) 여기서는 0으로 채운다 — CSV의 z와 같은 취급이다. + 파트가 여럿이면 가장 긴 것을 노선으로 본다. + """ + from B03_FileInput.B03_FileInput_Engine_Shapefile import ( + read_shapefile_attributes, + read_shapefile_parts, + shapefile_crs_input, + shapefile_epsg_label, + ) + + try: + parts = read_shapefile_parts(path) + except (OSError, ValueError, struct.error) as error: + logger.warning("계획 노선 shapefile을 읽지 못했습니다: %s (%s)", path, error) + return None + if not parts: + logger.warning("계획 노선 shapefile에 폴리라인이 없습니다: %s", path) + return None + + points = max(parts, key=len) + if len(points) < 2: + logger.warning("계획 노선 shapefile에 좌표가 2점 미만입니다: %s", path) + return None + + vertices: list[RouteVertex] = [] + cumulative = 0.0 + previous: tuple[float, float] | None = None + for x, y, z in points: + if previous is not None: + cumulative += math.dist(previous, (x, y)) + vertices.append(RouteVertex(x=x, y=y, z=z, chainage_m=cumulative)) + previous = (x, y) + + attributes = read_shapefile_attributes(path) + name = next( + ( + attributes[key] + for key in ("대상지", "노선명", "route_name", "name") + if attributes.get(key) + ), + path.stem, + ) + epsg = shapefile_epsg_label(path) + logger.info( + "계획 노선 %s: 정점 %d개, 연장 %.0fm, EPSG %s(라벨)", + path.name, + len(vertices), + cumulative, + epsg, + ) + return PlannedRoute( + vertices=vertices, + epsg=epsg, + name=name, + source=path, + crs_input=shapefile_crs_input(path), + ) + + +def read_planned_route(path: Path) -> PlannedRoute | None: + """계획 노선 파일을 확장자에 맞는 판독기로 읽는다(CSV·shapefile).""" + if path.suffix.lower() == ".shp": + return read_planned_route_shapefile(path) + return read_planned_route_csv(path) def find_planned_route_file(input_dir: Path) -> Path | None: - """B03 입력 폴더에서 계획 노선 파일을 찾는다. 여러 개면 가장 최근 것.""" + """B03 입력 폴더에서 계획 노선 파일을 찾는다. + + shapefile을 CSV보다 우선한다 — 원청 정식 노선이 shapefile로 오고, CSV는 예전 + 자료거나 좌표만 뽑아 둔 보조본인 경우가 많다. 같은 종류가 여럿이면 가장 최근 것. + """ if not input_dir.exists(): return None - candidates = sorted( - input_dir.rglob("*.csv"), key=lambda item: item.stat().st_mtime, reverse=True - ) - return candidates[0] if candidates else None + for pattern in ("*.shp", "*.csv"): + candidates = sorted( + input_dir.rglob(pattern), key=lambda item: item.stat().st_mtime, reverse=True + ) + if candidates: + return candidates[0] + return None def build_route_vertices(points: list[dict[str, Any]]) -> list[RouteVertex]: diff --git a/config/config_frontend.ts b/config/config_frontend.ts index be85b77d..b8128140 100644 --- a/config/config_frontend.ts +++ b/config/config_frontend.ts @@ -42,7 +42,22 @@ export const PROGRESS_UPDATE_INTERVAL_MS = 10_000; export const SERVICE_WORKER_PATH = "/assets/B03_FileInput_ServiceWorker.js"; /** 허용 확장자 (계획노선/지형/포인트클라우드/도면) */ -export const UPLOAD_ALLOWED_EXT = [".csv", ".las", ".laz", ".tif", ".tfw", ".prj", ".dxf"] as const; +export const UPLOAD_ALLOWED_EXT = [ + ".csv", + ".shp", + ".shx", + ".dbf", + ".cpg", + ".las", + ".laz", + ".tif", + ".tfw", + ".prj", + ".dxf", +] as const; + +/** 계획노선 shapefile 한 벌 — 노선 슬롯에 통째로 담긴다. */ +export const SHAPEFILE_MEMBER_EXT = [".shp", ".shx", ".dbf", ".cpg", ".prj"] as const; /* ----------------------------------------------------------------------------- * 3. WebCAD / 3D 렌더링 옵션 diff --git a/config/config_system.py b/config/config_system.py index 9c310634..750896da 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -70,9 +70,26 @@ DB_POOL_MAX = int(os.getenv("DB_POOL_MAX", "20")) # 4. 파일 업로드 제한 # ───────────────────────────────────────────────────────────────────────── UPLOAD_MAX_MB = int(os.getenv("UPLOAD_MAX_MB", str(30 * 1024))) -UPLOAD_MAX_FILES = int(os.getenv("UPLOAD_MAX_FILES", "5")) +# 한 번에 보낼 수 있는 **파일 수**. 화면의 슬롯 수(5)와 다르다 — 계획노선 shapefile은 +# 한 슬롯에 5개(.shp/.shx/.dbf/.cpg/.prj)가 한 벌로 들어오기 때문이다(2026-08-31). +UPLOAD_MAX_FILES = int(os.getenv("UPLOAD_MAX_FILES", "10")) UPLOAD_CHUNK_SIZE_BYTES = int(os.getenv("UPLOAD_CHUNK_SIZE_BYTES", str(1024 * 1024 * 1024))) -UPLOAD_ALLOWED_EXT = [".csv", ".las", ".laz", ".tif", ".tfw", ".prj", ".dxf"] +UPLOAD_ALLOWED_EXT = [ + ".csv", + ".shp", + ".shx", + ".dbf", + ".cpg", + ".las", + ".laz", + ".tif", + ".tfw", + ".prj", + ".dxf", +] +# 계획노선 shapefile 한 벌. GDAL이 열려면 같은 basename으로 한 폴더에 있어야 한다. +SHAPEFILE_MEMBER_EXT = (".shp", ".shx", ".dbf", ".cpg", ".prj") +SHAPEFILE_REQUIRED_EXT = (".shp", ".shx", ".dbf") CHUNK_TEMP_DIR = os.getenv("CHUNK_TEMP_DIR", "B03_FileInput/chunks_temp") CHUNK_RETENTION_HOURS = int(os.getenv("CHUNK_RETENTION_HOURS", "24"))