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_Api_Fetch.ts b/B03_FileInput/B03_FileInput_Api_Fetch.ts index d2f53bb2..27265301 100644 --- a/B03_FileInput/B03_FileInput_Api_Fetch.ts +++ b/B03_FileInput/B03_FileInput_Api_Fetch.ts @@ -177,6 +177,8 @@ export interface UploadOverviewFile { file_size_mb: number; status: string; uploaded_at: string | null; + /** 저장 경로 — PRJ 두 장(노선/지형)을 카드에 되돌릴 때 이것으로 가린다. */ + relative_path: string | null; } export interface UploadOverviewSession { 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..de194576 100644 --- a/B03_FileInput/B03_FileInput_Repository.py +++ b/B03_FileInput/B03_FileInput_Repository.py @@ -65,11 +65,19 @@ 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 입력과 실제 판독 대상이 갈리지 않는다. + + PRJ는 노선용·지형용 두 장이 온다. DB `file_type`은 둘 다 `prj`라 그대로 세면 노선 + PRJ 하나로 필수가 채워진다 — 프로젝트 좌표계를 정하는 것은 **지형 PRJ**이므로, + 노선 세트 폴더(`input/shp/`)에 있는 PRJ는 `route_prj`로 갈라 센다(2026-08-31). + """ async with connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( """ - SELECT id, LOWER(file_type) AS file_type + SELECT id, LOWER(file_type) AS file_type, raw_file_path FROM input_files WHERE project_id = %s AND status IN ('UPLOADED', 'PROCESSED') ORDER BY id DESC @@ -78,17 +86,27 @@ async def get_project_input_readiness( ) rows = await cursor.fetchall() - file_types = {str(row["file_type"]) for row in rows if row.get("file_type")} + file_types: set[str] = set() + for row in rows: + file_type = str(row.get("file_type") or "") + if not file_type: + continue + if file_type == "prj" and "/input/shp/" in str(row.get("raw_file_path") or ""): + file_type = "route_prj" + file_types.add(file_type) point_cloud_id = next( (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( @@ -309,7 +327,7 @@ async def list_project_input_files( await cursor.execute( """ SELECT f.id, f.file_type, f.original_filename, f.file_size_mb, f.status, - f.upload_at + f.upload_at, f.raw_file_path FROM input_files f INNER JOIN ( SELECT MAX(id) AS id 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..d63f236c 100644 --- a/B03_FileInput/B03_FileInput_Router.py +++ b/B03_FileInput/B03_FileInput_Router.py @@ -76,8 +76,13 @@ _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로 읽으므로 필수가 아니다. +# `route_prj`는 노선 세트 폴더에 있는 PRJ — 지형 PRJ(`prj`)와 따로 센다. +_SHAPEFILE_REQUIRED_TYPES = frozenset({"shx", "dbf", "route_prj"}) def _total_chunks(size_bytes: int, chunk_size_bytes: int) -> int: @@ -95,6 +100,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 +174,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) @@ -810,6 +820,7 @@ async def get_project_upload_overview( file_size_mb=float(row["file_size_mb"] or 0.0), status=str(row["status"]), uploaded_at=str(row["upload_at"]) if row.get("upload_at") else None, + relative_path=(str(row["raw_file_path"]) if row.get("raw_file_path") else None), ) for row in files ], diff --git a/B03_FileInput/B03_FileInput_Schema.py b/B03_FileInput/B03_FileInput_Schema.py index 3506e764..18d24b7a 100644 --- a/B03_FileInput/B03_FileInput_Schema.py +++ b/B03_FileInput/B03_FileInput_Schema.py @@ -128,6 +128,9 @@ class UploadOverviewFile(BaseModel): file_size_mb: float status: str uploaded_at: str | None = None + # PRJ는 노선용·지형용 두 장이 온다. 확장자로는 못 가리므로 저장 폴더로 가린다 + # (노선 세트는 `B03_FileInput/input/shp/`에 모인다, 2026-08-31). + relative_path: str | None = None class UploadOverviewSession(BaseModel): diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index bc4394ae..cd4247bc 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -28,9 +28,9 @@ logger = logging.getLogger(__name__) def _planned_route_points_in_project_crs( project_root: Path, surface: dict[str, Any] | None = None ) -> list[dict[str, float]] | None: - """계획노선 CSV를 읽어 프로젝트 좌표계(m) 점 목록으로 돌려준다. 없으면 None. + """계획노선 파일(CSV·shapefile)을 읽어 프로젝트 좌표계(m) 점 목록으로 돌려준다. - B04 `/planned-route` 조회와 같은 규칙 — CSV가 제 좌표계(crs_epsg)를 적어 두었고 + B04 `/planned-route` 조회와 같은 규칙 — 파일이 제 좌표계를 싣고 있고 프로젝트 좌표계와 다르면 한 번 옮긴다. `surface`(확정 필터·방식·스무딩)를 받으면 지표면이 덮지 못하는 구간을 잘라 낸다. @@ -41,7 +41,7 @@ def _planned_route_points_in_project_crs( from common_util.common_util_route_geometry import ( densify_route, find_planned_route_file, - read_planned_route_csv, + read_planned_route, trim_route_to_surface, ) from config.config_system import ( @@ -51,12 +51,12 @@ def _planned_route_points_in_project_crs( ) 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..463629f7 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -44,6 +44,11 @@ import { getExtension, initializeSlots, makeSessionKey, + planSlotAssignments, + ROUTE_SLOTS, + SHAPEFILE_DEPENDENT_SLOTS, + slotConfigs, + TERRAIN_SLOTS, type FileSlot, type FileSlotState, type StoredUploadSession, @@ -193,10 +198,21 @@ export async function renderB03FileInput(root: HTMLElement): Promise { pageError.textContent = validation ?? ""; } + /** 확장자 줄 — 지금 필수인지에 따라 "· 선택" 꼬리표가 붙고 떨어진다. */ + function renderExtensionLabel(card: HTMLElement, state: FileSlotState): void { + const extLabel = state.extensions.join(", "); + const target = card.querySelector(".b03-file__card-ext"); + if (!target) return; + target.textContent = isSlotRequired(state) + ? extLabel + : `${extLabel} · ${L("B03_File_Card_Optional")}`; + } + function renderSlot(slot: FileSlot): void { const state = slots.get(slot); const card = cardMap.get(slot); if (!state || !card) return; + renderExtensionLabel(card, state); const fileName = card.querySelector( ".b03-file__file-name", @@ -290,12 +306,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { file: File, targetSlot?: FileSlot, ): Promise { - const extension = getExtension(file.name); - const state = targetSlot - ? slots.get(targetSlot) - : Array.from(slots.values()).find((candidate) => - candidate.extensions.includes(extension), - ); + const state = targetSlot ? slots.get(targetSlot) : undefined; if (!state) { pageError.textContent = `${L("B03_File_Error_Extension")} ${file.name}`; return; @@ -330,6 +341,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise { state.etaSeconds = null; state.error = undefined; renderSlot(state.slot); + // 노선 도형이 CSV↔shapefile로 바뀌면 형제 카드의 필수 표시도 따라 바뀐다. + if (state.slot === "csv") renderRouteDependentSlots(); + } + + function renderRouteDependentSlots(): void { + for (const slot of SHAPEFILE_DEPENDENT_SLOTS) renderSlot(slot); + updateUploadButton(); } function onFileSelected( @@ -353,14 +371,17 @@ export async function renderB03FileInput(root: HTMLElement): Promise { // 개수는 "고른 파일 수"가 아니라 **최종적으로 차는 슬롯 수**로 센다. // 같은 슬롯을 다시 고르는 것은 교체라 개수가 늘지 않는다 — 더하기로 세면 5개를 고른 // 뒤 파일 선택 영역으로 하나만 바꾸려 해도 초과로 막힌다(2026-08-08). + // 파일 하나에 카드 하나다. `.prj`만 확장자로 안 갈리므로 노선 도형과 basename이 + // 같은지로 노선/지형 좌표계 카드를 정한다(2026-08-31 사용자 지시). + const routeFile = slots.get("csv")?.file?.name; + const assignments = planSlotAssignments( + files, + slotConfigs(), + routeFile ? routeFile.replace(/\.[^.]*$/, "") : undefined, + ); const occupied = new Set(selectedStates().map((state) => state.slot)); - for (const file of files) { - const extension = getExtension(file.name); - const slot = - targetSlot ?? - Array.from(slots.values()).find((candidate) => - candidate.extensions.includes(extension), - )?.slot; + for (const item of assignments) { + const slot = targetSlot ?? item.slot; if (slot) occupied.add(slot); } if (occupied.size > UPLOAD_MAX_FILES) { @@ -369,7 +390,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); + for (const item of assignments) { + const slot = targetSlot ?? item.slot; + if (!slot) { + pageError.textContent = `${L("B03_File_Error_Extension")} ${item.file.name}`; + continue; + } + await assignFileToSlot(item.file, slot); + } await detectPausedUploads(); })(); } @@ -388,6 +416,27 @@ export async function renderB03FileInput(root: HTMLElement): Promise { state.etaSeconds = null; state.error = undefined; renderSlot(slot); + if (slot === "csv") renderRouteDependentSlots(); + } + + /** 노선 도형이 shapefile인가 — 로컬 선택과 서버 정본을 함께 본다. */ + function routeIsShapefile(): boolean { + const state = slots.get("csv"); + const name = state?.file?.name ?? state?.serverUploaded?.name; + return getExtension(name ?? "") === ".shp"; + } + + /** + * 이 카드가 지금 필수인가. + * + * shapefile 형제 카드(.shx/.dbf/노선 .prj)는 노선 도형이 shapefile일 때만 필수다 — + * CSV 한 장으로 넣는 흐름을 막으면 안 된다(2026-08-31). + */ + function isSlotRequired(state: FileSlotState): boolean { + if (SHAPEFILE_DEPENDENT_SLOTS.includes(state.slot)) + return routeIsShapefile(); + if (state.slot === "las_laz") return !lasFreeDesign; + return state.isRequired; } function validateSlots(): string | null { @@ -399,11 +448,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { // 재접속 후 파일 하나만 교체 업로드하는 흐름을 막지 않는다(2026-08-04 사용자 지시). const missingRequired = Array.from(slots.values()).some( (state) => - state.isRequired && - !state.file && - !state.serverUploaded && - // LAS 없는 설계면 포인트클라우드 카드는 필수에서 뺀다. - !(lasFreeDesign && state.slot === "las_laz"), + isSlotRequired(state) && !state.file && !state.serverUploaded, ); if (missingRequired) return L("B03_File_Error_RequiredSlots"); if (!lasFreeDesign) { @@ -435,9 +480,20 @@ export async function renderB03FileInput(root: HTMLElement): Promise { for (const state of slots.values()) state.serverUploaded = undefined; for (const file of overview.files) { const extension = `.${file.file_type.toLowerCase()}`; - const state = Array.from(slots.values()).find((candidate) => - candidate.extensions.includes(extension), - ); + // PRJ 두 장은 확장자가 같다 — 노선 세트는 `input/shp/`에 모여 있으므로 + // 저장 경로로 가린다(2026-08-31). + const inRouteSet = (file.relative_path ?? "").includes("/input/shp/"); + const slot: FileSlot | undefined = + extension === ".prj" + ? inRouteSet + ? "route_prj" + : "prj" + : Array.from(slots.values()).find( + (candidate) => + candidate.slot !== "route_prj" && + candidate.extensions.includes(extension), + )?.slot; + const state = slot ? slots.get(slot) : undefined; if (state) { state.serverUploaded = { name: file.original_filename, @@ -480,11 +536,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { card.querySelector(".b03-file__card-label")!.textContent = L( state.labelKey, ); - // 지형 래스터만 선택 항목이라 확장자 옆에 표시해 둔다. - const extLabel = state.extensions.join(", "); - card.querySelector(".b03-file__card-ext")!.textContent = state.isRequired - ? extLabel - : `${extLabel} · ${L("B03_File_Card_Optional")}`; + renderExtensionLabel(card, state); const input = card.querySelector( ".b03-file__slot-input", )!; @@ -512,15 +564,24 @@ export async function renderB03FileInput(root: HTMLElement): Promise { function createCardGroup( title: string, groupSlots: readonly FileSlot[], + modifier?: string, + hint?: string, ): HTMLElement { const group = document.createElement("section"); group.className = "b03-file__group"; + if (modifier) group.classList.add(modifier); if (title) { const groupTitle = document.createElement("h3"); groupTitle.className = "b03-file__group-title"; groupTitle.textContent = title; group.append(groupTitle); } + if (hint) { + const groupHint = document.createElement("p"); + groupHint.className = "b03-file__group-hint"; + groupHint.textContent = hint; + group.append(groupHint); + } const content = document.createElement("div"); content.className = "b03-file__group-content"; for (const slot of groupSlots) { @@ -768,15 +829,21 @@ export async function renderB03FileInput(root: HTMLElement): Promise { resultList, ); - // 계획노선과 지형 자료는 지형 래스터(tif)만 빼면 모두 필수라 따로 묶지 않는다 - // (2026-08-08 사용자 지시). - const inputsGroup = createCardGroup(L("B03_File_Group_Inputs"), [ - "csv", - "las_laz", - "prj", - "tfw", - "tif", - ]); + // 자료의 출처가 둘로 갈린다 — 원청이 준 계획노선, 측량이 준 지형(LAS·래스터). + // 좌표계 파일(.prj)도 각각 하나씩 오므로 컨테이너를 나눠야 어느 칸에 무엇을 넣는지 + // 화면만 보고 안다(2026-08-31 사용자 지시). + const routeGroup = createCardGroup( + L("B03_File_Group_Route"), + ROUTE_SLOTS, + "b03-file__group--route", + L("B03_File_Group_Route_Hint"), + ); + const terrainGroup = createCardGroup( + L("B03_File_Group_Terrain"), + TERRAIN_SLOTS, + "b03-file__group--terrain", + L("B03_File_Group_Terrain_Hint"), + ); // LAS 없는 설계 토글 — 켜면 포인트클라우드 카드를 비활성화하고 필수에서 뺀다. const lasFreeRow = document.createElement("label"); @@ -814,10 +881,22 @@ export async function renderB03FileInput(root: HTMLElement): Promise { pageError.textContent = ""; }); - const cardsContainer = document.createElement("div"); - cardsContainer.className = + // LAS 토글은 지형 컨테이너의 것이다 — 켜면 그 안의 포인트클라우드 카드만 잠긴다. + terrainGroup.append(lasFreeRow); + + const routePanel = document.createElement("div"); + routePanel.className = "b03-file__control-panel b03-file__cards-container-panel"; - cardsContainer.append(lasFreeRow, inputsGroup); + routePanel.append(routeGroup); + + const terrainPanel = document.createElement("div"); + terrainPanel.className = + "b03-file__control-panel b03-file__cards-container-panel"; + terrainPanel.append(terrainGroup); + + const cardsContainer = document.createElement("div"); + cardsContainer.className = "b03-file__columns"; + cardsContainer.append(routePanel, terrainPanel); const workflowState = activeProjectId ? await fetchWorkflowState(activeProjectId).catch(() => undefined) diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index ba5c4e5a..550b1ed7 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -180,12 +180,27 @@ gap: var(--spacing-32); } +/* 계획노선 | 지형(LAS) — 컨테이너를 둘로 나눈다(2026-08-31 사용자 지시). + 노선은 카드 5장(shapefile 한 벌), 지형은 4장이라 폭을 5:4 비슷하게 준다. */ +.b03-file__columns { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--spacing-24); + align-items: start; +} + .b03-file__group { display: flex; flex-direction: column; gap: var(--spacing-20); } +.b03-file__group-hint { + font-size: var(--text-body-sm, 14px); + color: var(--color-slate, #615e6e); + margin: calc(var(--spacing-8) * -1) 0 0 0; +} + .b03-file__group-title { font-size: var(--text-subheading, 24px); color: var(--color-deep-iris, #26114a); @@ -195,7 +210,7 @@ .b03-file__group-content { display: grid; - grid-template-columns: repeat(3, 1fr); /* 3열 구조 */ + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--spacing-24); } @@ -260,14 +275,17 @@ border-color: var(--color-danger, #dc2626); } +/* 카드가 좁아졌다(컨테이너 2열 × 카드 2열, 2026-08-31). 격자로 고정하면 제목이 + 글자 단위로 접히므로, 자리가 모자라면 배지·삭제 버튼이 다음 줄로 내려가게 한다. */ .b03-file__card-header { - display: grid; - grid-template-columns: auto 1fr auto auto; /* 아이콘 및 닫기 버튼 주변 확보 */ - gap: var(--spacing-12); + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); align-items: center; } .b03-file__card-icon { + flex: 0 0 auto; width: 28px; height: 28px; border-radius: var(--radius-icons, 8px); @@ -284,6 +302,7 @@ .b03-file__card-heading { min-width: 0; + flex: 1 1 55%; display: flex; flex-direction: column; gap: 2px; @@ -294,6 +313,11 @@ color: var(--color-deep-iris, #26114a); font-size: var(--text-body-sm, 14px); font-weight: var(--font-weight-medium, 500); + /* 한글은 글자 단위로 끊기므로 어절을 지켜 준다 — "계획 +노선 +도형" 방지. */ + word-break: keep-all; + overflow-wrap: break-word; } .b03-file__card-ext, @@ -306,6 +330,7 @@ .b03-file__card-badge-container { display: flex; align-items: center; + margin-left: auto; margin-right: var(--spacing-4); } @@ -463,9 +488,10 @@ border-bottom: 0; } -@media (max-width: 1280px) { - .b03-file__group-content { - grid-template-columns: repeat(2, 1fr); /* 좁은 창에서는 2열 */ +@media (max-width: 1440px) { + /* 좁아지면 두 컨테이너를 위아래로 쌓는다 — 카드가 눌려 글자가 접히는 것을 막는다. */ + .b03-file__columns { + grid-template-columns: 1fr; } } diff --git a/B03_FileInput/B03_FileInput_UI_Support.ts b/B03_FileInput/B03_FileInput_UI_Support.ts index 97ea273a..ea3502f9 100644 --- a/B03_FileInput/B03_FileInput_UI_Support.ts +++ b/B03_FileInput/B03_FileInput_UI_Support.ts @@ -1,6 +1,45 @@ import { ui_locales } from "@ui/ui_template_locale"; -export type FileSlot = "csv" | "las_laz" | "prj" | "tfw" | "tif" | "dxf"; +/** + * 카드 한 장 = 파일 한 개. 계획노선 shapefile은 파일이 다섯이므로 카드도 다섯이다 + * (2026-08-31 사용자 지시) — 어느 파일이 왔고 어느 것이 비었는지 화면에서 바로 보인다. + * `route_prj`(노선 좌표계)와 `prj`(지형 좌표계)는 확장자가 같아 basename으로 가른다. + */ +export type FileSlot = + | "csv" + | "shx" + | "dbf" + | "cpg" + | "route_prj" + | "las_laz" + | "prj" + | "tfw" + | "tif" + | "dxf"; + +/** 왼쪽(계획노선) 컨테이너에 놓이는 슬롯. */ +export const ROUTE_SLOTS: readonly FileSlot[] = [ + "csv", + "shx", + "dbf", + "cpg", + "route_prj", +]; + +/** 오른쪽(지형·LAS) 컨테이너에 놓이는 슬롯. */ +export const TERRAIN_SLOTS: readonly FileSlot[] = [ + "las_laz", + "prj", + "tfw", + "tif", +]; + +/** 노선 도형이 shapefile일 때 함께 있어야 하는 슬롯(.cpg는 없으면 CP949). */ +export const SHAPEFILE_DEPENDENT_SLOTS: readonly FileSlot[] = [ + "shx", + "dbf", + "route_prj", +]; export type UploadStatus = "pending" | "uploading" | "completed" | "failed"; export interface SlotConfig { @@ -45,9 +84,37 @@ const SLOT_CONFIGS: readonly SlotConfig[] = [ slot: "csv", labelKey: "B03_File_Slot_PlannedRoute", icon: "⌁", - extensions: [".csv"], + extensions: [".csv", ".shp"], isRequired: true, }, + { + slot: "shx", + labelKey: "B03_File_Slot_RouteIndex", + icon: "⋮", + extensions: [".shx"], + isRequired: false, + }, + { + slot: "dbf", + labelKey: "B03_File_Slot_RouteAttribute", + icon: "▤", + extensions: [".dbf"], + isRequired: false, + }, + { + slot: "cpg", + labelKey: "B03_File_Slot_RouteEncoding", + icon: "⌨", + extensions: [".cpg"], + isRequired: false, + }, + { + slot: "route_prj", + labelKey: "B03_File_Slot_RouteProjection", + icon: "◈", + extensions: [".prj"], + isRequired: false, + }, { slot: "las_laz", labelKey: "B03_File_Slot_PointCloud", @@ -83,6 +150,48 @@ 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; +} + +/** + * 고른 파일을 카드(슬롯)에 배정한다. + * + * `.prj`만 확장자로 갈리지 않는다 — 노선 좌표계와 지형 좌표계가 같은 확장자다. + * **노선 도형(.shp)과 basename이 같은 것**만 노선 좌표계 카드로 보내고, 나머지는 + * 지형 좌표계 카드로 보낸다. `routeStem`은 이미 골라 둔 노선 도형의 basename으로, + * 노선 PRJ를 나중에 따로 추가하는 경우를 받아 준다. + */ +export function planSlotAssignments( + files: readonly File[], + slotConfigs: readonly SlotConfig[], + routeStem?: string, +): { file: File; slot?: FileSlot }[] { + const batchStem = files + .filter((file) => getExtension(file.name) === ".shp") + .map((file) => getBaseName(file.name))[0]; + const stem = batchStem ?? routeStem; + return files.map((file) => { + const extension = getExtension(file.name); + if (extension === ".prj") { + const isRoute = stem !== undefined && getBaseName(file.name) === stem; + return { file, slot: (isRoute ? "route_prj" : "prj") as FileSlot }; + } + const config = slotConfigs.find( + (candidate) => + candidate.slot !== "route_prj" && + candidate.extensions.includes(extension), + ); + return { file, slot: config?.slot }; + }); +} + +/** 슬롯 설정 목록 — 배정 규칙이 카드 정의와 같은 것을 쓰도록 밖으로 연다. */ +export function slotConfigs(): readonly SlotConfig[] { + return SLOT_CONFIGS; +} + 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 fe7e2bcc..15025307 100644 --- a/B04_PreProcess/B04_PreProcess_Engine.py +++ b/B04_PreProcess/B04_PreProcess_Engine.py @@ -301,11 +301,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/B04_PreProcess/B04_PreProcess_Router_Inflow.py b/B04_PreProcess/B04_PreProcess_Router_Inflow.py index 594e143a..ab520978 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Inflow.py +++ b/B04_PreProcess/B04_PreProcess_Router_Inflow.py @@ -27,7 +27,7 @@ from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import GridSpec from B05_Profile.B05_Profile_Repository import get_surface_crs_epsg from common_util.common_util_route_geometry import ( find_planned_route_file, - read_planned_route_csv, + read_planned_route, ) from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool @@ -80,7 +80,7 @@ async def _resolve_epsg(project_id: UUID, stored_path: str) -> str: epsg = await get_surface_crs_epsg(connection, project_id, 0) 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 source = (planned.epsg if planned else None) or epsg or 5186 return f"EPSG:{source}" diff --git a/B04_PreProcess/B04_PreProcess_Router_Watershed.py b/B04_PreProcess/B04_PreProcess_Router_Watershed.py index 54949168..d77acbc9 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Watershed.py +++ b/B04_PreProcess/B04_PreProcess_Router_Watershed.py @@ -39,7 +39,7 @@ from B05_Profile.B05_Profile_Repository import get_surface_crs_epsg from common_util.common_util_route_geometry import ( StructureCandidate, find_planned_route_file, - read_planned_route_csv, + read_planned_route, ) from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_wamis_rainfall import ( @@ -172,7 +172,7 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse: status_code=404, content={"status": "error", "message": "계획 노선 파일이 업로드되지 않았습니다."}, ) - planned = read_planned_route_csv(route_file) + planned = read_planned_route(route_file) if planned is None or len(planned.vertices) < 2: return JSONResponse( status_code=400, @@ -246,7 +246,7 @@ def _route_center_lonlat(stored_path: str, fallback_epsg: int | None) -> tuple[f route_file = find_planned_route_file(_route_input_dir(stored_path)) if route_file is None: return None - planned = read_planned_route_csv(route_file) + planned = read_planned_route(route_file) if planned is None or not planned.vertices: return None middle = planned.vertices[len(planned.vertices) // 2] diff --git a/common_util/common_util_crs.py b/common_util/common_util_crs.py index 6717c9f4..fb56efbe 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,48 @@ def identify_epsg(crs: CRS, wkt_text: str | None = None) -> int | None: return None +_TERRAIN_DATA_GLOBS = ("las/*", "laz/*", "tif/*", "tfw/*") + + +def find_project_prj(project_root: Path) -> Path | None: + """프로젝트 **작업 좌표계**를 정하는 PRJ를 고른다. + + 자료가 둘 이상의 좌표계로 들어온다(실측 2026-08-31 — 노선 shapefile은 UTM-K, + 지형은 동부원점 Bessel). 서피스 격자가 모델좌표의 주인이므로 **지형 PRJ**를 쓴다. + 노선 PRJ는 shapefile 세트 폴더(`input/shp/`)에 있어 여기서 섞이지 않는다. + + `input/prj/`에도 PRJ가 여럿 쌓인다 — 자료를 다시 올려도 파일명이 다르면 옛 PRJ가 + 남기 때문이다. 이름 정렬로 고르면 잔재를 집는다(실측 2026-08-31: 옛 `result.prj` + (5187)가 새 `용화.prj`(5176)보다 앞서 뽑혀 노선이 Y로 100,000m 어긋났고 B05 + 경로 계산이 400으로 실패했다). 그래서 **지금 쓰는 지형 자료(LAS·TIF·TFW)와 + basename이 같은 PRJ**를 먼저 찾고, 못 찾으면 가장 최근 것을 쓴다. + """ + prj_dir = project_root / "B03_FileInput" / "input" / "prj" + candidates = sorted(prj_dir.glob("*.prj")) if prj_dir.is_dir() else [] + if candidates: + if len(candidates) == 1: + return candidates[0] + input_root = project_root / "B03_FileInput" / "input" + terrain_stems = { + path.stem + for pattern in _TERRAIN_DATA_GLOBS + for path in input_root.glob(pattern) + if path.is_file() + } + paired = [path for path in candidates if path.stem in terrain_stems] + pool = paired or candidates + chosen = max(pool, key=lambda path: path.stat().st_mtime) + if not paired: + logger.warning( + "지형 자료와 짝이 되는 PRJ를 찾지 못해 가장 최근 PRJ를 씁니다: %s", chosen.name + ) + return chosen + remainder = sorted(project_root.glob("B03_FileInput/**/*.prj")) + # 노선 세트 폴더의 PRJ는 노선 좌표계라 프로젝트 좌표계가 될 수 없다. + remainder = [path for path in remainder if path.parent.name != "shp"] or remainder + 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_drainage_context.py b/common_util/common_util_drainage_context.py index 9bc3b0f3..bce2711f 100644 --- a/common_util/common_util_drainage_context.py +++ b/common_util/common_util_drainage_context.py @@ -31,7 +31,7 @@ from common_util.common_util_route_geometry import ( RouteVertex, build_route_vertices, find_planned_route_file, - read_planned_route_csv, + read_planned_route, ) from common_util.common_util_route_profile import Z_SOURCE_CSV, resolve_route_profile from common_util.common_util_storage import resolve_stored_project_path @@ -114,7 +114,7 @@ async def load_drainage_context(project_id: UUID) -> tuple[DrainageContext | Non def _read_planned_route(project_root: Path): """원청 계획노선 CSV를 찾아 읽는다(파일 접근이라 스레드에서 돈다).""" path = find_planned_route_file(project_root / _INPUT_SUBDIR) - return read_planned_route_csv(path) if path else None + return read_planned_route(path) if path else None def _open_sampler(project_root: Path, surface_params: dict[str, Any]): diff --git a/common_util/common_util_route_geometry.py b/common_util/common_util_route_geometry.py index 39511de4..72cf3687 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 @@ -62,6 +63,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: @@ -111,17 +116,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 trim_route_to_surface( diff --git a/config/config_frontend.ts b/config/config_frontend.ts index a8321ebb..bdd275a1 100644 --- a/config/config_frontend.ts +++ b/config/config_frontend.ts @@ -35,7 +35,9 @@ export const CURRENT_PROJECT_ID_KEY = "frd_current_project_id"; export const UPLOAD_MAX_MB = 30 * 1024; /** 한 요청에서 선택 가능한 최대 파일 수 */ -export const UPLOAD_MAX_FILES = 5; +// 한 번에 채울 수 있는 **카드 수**. 계획노선 shapefile이 카드 5장을 쓰므로 +// 노선 5 + 지형 4 = 9가 최대다(2026-08-31). +export const UPLOAD_MAX_FILES = 10; /** 청크 업로드 단위 (MB) */ export const UPLOAD_CHUNK_SIZE_MB = 1024; @@ -47,7 +49,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 8a142020..e501e962 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")) diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index c61d1168..95f44e1b 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -276,8 +276,8 @@ export const ui_locales_b1 = { ], B03_File_Select_Label: ["입력 파일 선택", "Select input files"], B03_File_Select_Hint: [ - "계획노선 CSV, LAS/LAZ 1개, PRJ, TFW를 선택하세요. TIF는 선택 사항입니다.", - "Select a planned-route CSV, one LAS/LAZ, PRJ, and TFW. TIF is optional.", + "계획노선(CSV 또는 shapefile 5개)과 LAS/LAZ 1개, 지형 PRJ·TFW를 함께 고르면 카드에 나뉩니다. TIF는 선택 사항입니다.", + "Pick the planned route (a CSV or the five shapefile files), one LAS/LAZ, and the terrain PRJ and TFW together — they are sorted into cards. TIF is optional.", ], B03_File_Selected_Title: ["선택한 파일", "Selected files"], B03_File_Selected_Empty: ["선택한 파일이 없습니다.", "No files selected."], @@ -330,9 +330,23 @@ export const ui_locales_b1 = { B03_File_Result_Path: ["저장 경로", "Stored path"], B03_File_Group_Required: ["필수 파일", "Required files"], B03_File_Group_Optional: ["선택 파일", "Optional files"], - /* 지형 래스터만 선택 항목이라 계획노선·지형 자료를 한 묶음으로 둔다(2026-08-08). */ + /* 자료 출처가 갈려 컨테이너를 둘로 나눈다 — 계획노선 / 지형(LAS)(2026-08-31). */ B03_File_Group_Inputs: ["입력 자료", "Input files"], - B03_File_Slot_PlannedRoute: ["계획노선 좌표", "Planned Route Coordinates"], + B03_File_Group_Route: ["계획노선 자료", "Planned route files"], + B03_File_Group_Terrain: ["지형 자료 (LAS)", "Terrain files (LAS)"], + B03_File_Group_Route_Hint: [ + "shapefile은 파일 5개가 한 벌입니다. CSV 한 장으로 넣어도 됩니다.", + "A shapefile is a set of five files. A single CSV also works.", + ], + B03_File_Group_Terrain_Hint: [ + "여기의 PRJ·TFW는 지형 자료의 좌표계입니다 — 노선 PRJ와 별개입니다.", + "The PRJ and TFW here describe the terrain data, separate from the route PRJ.", + ], + B03_File_Slot_PlannedRoute: ["계획노선 도형", "Planned Route Geometry"], + B03_File_Slot_RouteIndex: ["노선 도형 색인", "Route Shape Index"], + B03_File_Slot_RouteAttribute: ["노선 속성", "Route Attributes"], + B03_File_Slot_RouteEncoding: ["노선 속성 인코딩", "Route Attribute Encoding"], + B03_File_Slot_RouteProjection: ["노선 좌표계", "Route Projection"], B03_File_Slot_PointCloud: ["포인트클라우드", "Point Cloud"], B03_File_LasFree_Toggle: [ "LAS 없이 설계 (도엽등고선 기반)", @@ -342,7 +356,7 @@ export const ui_locales_b1 = { "포인트클라우드 없이 1:5,000 수치지형도 등고선으로 지형을 만듭니다.", "Terrain is built from 1:5,000 map sheet contours without a point cloud.", ], - B03_File_Slot_Projection: ["좌표계 정의", "Projection"], + B03_File_Slot_Projection: ["지형 좌표계", "Terrain Projection"], B03_File_Slot_RasterCoord: ["래스터 좌표", "Raster Coord 1"], B03_File_Slot_TerrainDem: ["지형 래스터", "Terrain DEM"], B03_File_Slot_CadDrawing: ["CAD 도면", "CAD Drawing"], @@ -385,8 +399,9 @@ export const ui_locales_b1 = { "A file for this slot is already selected.", ], B03_File_Error_RequiredSlots: [ - "필수 파일(계획노선 CSV, LAS/LAZ, PRJ, TFW)을 모두 선택하세요.", - "Select all required files: planned-route CSV, LAS/LAZ, PRJ, and TFW.", + "필수 카드를 모두 채우세요 — 계획노선(CSV 또는 shapefile 한 벌), LAS/LAZ, 지형 PRJ·TFW.", + "Fill every required card: the planned route (a CSV or a full shapefile set), " + + "LAS/LAZ, and the terrain PRJ and TFW.", ], B03_File_Error_SlotType: [ "선택한 파일 유형이 이 카드와 맞지 않습니다.",