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 b7b69883..de3a99b7 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -25,31 +25,26 @@ from fastapi.responses import JSONResponse logger = logging.getLogger(__name__) -def _planned_route_points_in_project_crs(project_root: Path) -> list[dict[str, float]] | None: - """계획노선 CSV를 읽어 프로젝트 좌표계(m) 점 목록으로 돌려준다. 없으면 None. +def _planned_route_points_in_project_crs( + project_root: Path, surface: dict[str, Any] | None = None +) -> list[dict[str, float]] | None: + """설계용 계획노선을 프로젝트 좌표계(m) 점 목록으로 돌려준다. 없으면 None. - B04 `/planned-route` 조회와 같은 규칙 — CSV가 제 좌표계(crs_epsg)를 적어 두었고 - 프로젝트 좌표계와 다르면 한 번 옮긴다. + 읽기·좌표계 변환·트림·조밀화는 `load_design_route()` 한 곳에서 한다 — 배수유역·유입도 + 같은 함수를 쓰므로 여기만 트림되는 일이 없다. """ - 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, - ) + from common_util.common_util_route_geometry import load_design_route - route_file = find_planned_route_file(project_root / "B03_FileInput" / "input") - planned = read_planned_route_csv(route_file) if route_file else None - if planned is None or len(planned.vertices) < 2: + planned = load_design_route(project_root, surface) + if planned is None: + if surface: + logger.warning( + "자동 설계 체인 중단(설계 노선 없음): 계획노선을 읽지 못했거나 라이다 측량" + " 범위와 겹치지 않습니다 — %s", + project_root.name, + ) 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 - if source_epsg.upper() != target_epsg.upper(): - from pyproj import Transformer - - transformer = Transformer.from_crs(source_epsg, target_epsg, always_xy=True) - points = [transformer.transform(x, y) for x, y in points] - return [{"x": x, "y": y} for x, y in points] + return [{"x": v.x, "y": v.y} for v in planned.vertices] async def _prepare_drainage_pipes_and_reprofile( @@ -172,16 +167,18 @@ async def run_auto_design_chain( # 이 체인이 끝나기 전에는 B05·B06에 들어가면 안 된다 — 반쯤 계산된 화면을 만지면 # 그 편집이 섞인 채 초기값이 찍힌다(2026-08-29 사용자 확정, CLAUDE.md 5장). mark_designing(project_root) - points = _planned_route_points_in_project_crs(project_root) + # WF1이 **실제로 확정한** 선택값(stage 1 스냅샷)을 그대로 쓴다. config 기본값을 + # 쓰면 LAS 없이 설계한 프로젝트에서 있지도 않은 모델을 가리켜 404로 체인이 + # 끊긴다(2026-08-30 실사고). 노선 트림도 이 지표면을 기준으로 한다. + async with pool.acquire() as connection: + defaults = await get_surface_confirmation_params(connection, str(project_id)) + + points = _planned_route_points_in_project_crs(project_root, defaults) if not points: logger.warning("자동 설계 체인 중단(계획노선 CSV 없음): project_id=%s", project_id) return None - # 3) B05 경로 계산 — WF1이 **실제로 확정한** 선택값(stage 1 스냅샷)을 그대로 쓴다. - # config 기본값(csf/dtm)을 쓰면 LAS 없이 설계한 프로젝트에서 있지도 않은 모델을 - # 가리켜 404로 체인이 끊긴다(2026-08-30 실사고). - async with pool.acquire() as connection: - defaults = await get_surface_confirmation_params(connection, str(project_id)) + # 3) B05 경로 계산 request = RouteSolveRequest( filter_key=str(defaults["source_filter"]), method=str(defaults["method"]), 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..f1e48d4b 100644 --- a/B04_PreProcess/B04_PreProcess_Engine.py +++ b/B04_PreProcess/B04_PreProcess_Engine.py @@ -23,7 +23,11 @@ from B04_PreProcess.B04_PreProcess_Engine_Pipeline import build_all_terrain_mode from B04_PreProcess.B04_PreProcess_Engine_Structurize import structurize_las from common_util.common_util_atomic import atomic_write_npz from common_util.common_util_json import atomic_write_json -from config.config_system import SURFACE_GROUND_RATIO_WARN, build_surface_model_config +from config.config_system import ( + SHEET_SURFACE_AUTO_METHODS, + SURFACE_GROUND_RATIO_WARN, + build_surface_model_config, +) logger = logging.getLogger(__name__) @@ -262,7 +266,10 @@ def run_surface_analysis( build_sheet_surface_from_route, ) - sheet_models = build_sheet_surface_from_route(project_root, processed_dir, models_dir) + # 자동 전처리는 기본 방식 하나만 만든다 — 나머지는 관리자가 화면에서 고를 때. + sheet_models = build_sheet_surface_from_route( + project_root, processed_dir, models_dir, list(SHEET_SURFACE_AUTO_METHODS) + ) except Exception as exc: logger.warning("도엽등고선 서피스 생성 실패: %s", exc) @@ -301,11 +308,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..dd52ea62 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py +++ b/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py @@ -446,25 +446,32 @@ def build_sheet_surface_model( def build_sheet_surface_from_route( - project_root: Path, processed_dir: Path, models_dir: Path + project_root: Path, + processed_dir: Path, + models_dir: Path, + methods: list[str] | None = None, ) -> list[dict[str, Any]]: - """B03 업로드 계획노선 CSV를 찾아 방식별 도엽 서피스를 만든다. 없으면 빈 목록.""" + """B03 업로드 계획노선을 찾아 지정한 방식의 도엽 서피스를 만든다. 없으면 빈 목록. + + `methods`를 주지 않으면 `SHEET_SURFACE_METHODS` 전체를 만든다 — 관리자가 화면에서 + 한 방식을 요청할 때 그 목록만 넘긴다. + """ 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 [] route_xy = np.array([(v.x, v.y) for v in planned.vertices], dtype=np.float64) return build_sheet_surface_model( - project_root, processed_dir, models_dir, route_xy, planned.epsg or 5186 + project_root, processed_dir, models_dir, route_xy, planned.epsg or 5186, methods ) @@ -478,7 +485,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 +497,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.py b/B04_PreProcess/B04_PreProcess_Router.py index a1df3a3d..8615af91 100644 --- a/B04_PreProcess/B04_PreProcess_Router.py +++ b/B04_PreProcess/B04_PreProcess_Router.py @@ -22,6 +22,7 @@ from B04_PreProcess.B04_PreProcess_Engine_Extent import ( planned_route_bounds, project_epsg_from_prj, ) +from B04_PreProcess.B04_PreProcess_Engine_Ground import available_filters from B04_PreProcess.B04_PreProcess_Repository import ( clear_confirmed_surface_models, get_input_file, @@ -325,7 +326,9 @@ async def get_surface_point_cloud( source_path = structured_path if filter is not None: - if filter not in {"grid_min_z", "csf", "pmf"}: + # 필터 목록은 Ground 의 등록부 하나에서 나온다 — 여기 따로 적어 두면 + # 필터가 늘 때마다 이 화면만 400으로 막힌다(2026-09-01 실사고). + if filter not in available_filters(): return JSONResponse( status_code=400, content={"status": "error", "message": "지원하지 않는 지면 필터입니다."}, 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..c5bf9233 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Watershed.py +++ b/B04_PreProcess/B04_PreProcess_Router_Watershed.py @@ -39,9 +39,11 @@ 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, + load_design_route, + read_planned_route, ) from common_util.common_util_storage import resolve_stored_project_path +from common_util.common_util_surface_confirmation import get_surface_confirmation_params from common_util.common_util_wamis_rainfall import ( build_rainfall_table, ensure_contour_cache, @@ -160,30 +162,35 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse: 노선은 **B03에 업로드된 계획 노선 파일**에서 읽는다 — B05의 확정 경로가 아니다. 배수유역 분석은 노선 설계보다 먼저 끝나 있어야 하기 때문이다(2026-07-31 사용자 지시). + 다만 설계 계통과 **같은 노선**이어야 한다 — `load_design_route()`가 지표면 밖 구간을 + 잘라 프로젝트 좌표계로 돌려준다. 원본을 그대로 쓰면 유역·관이 확정 노선 밖에도 + 찍히고 좌표계마저 갈린다(2026-09-01 실측: 관은 5179, 노선은 5176이었다). """ pool = get_db_pool() async with pool.acquire() as connection: stored_path = await get_project_storage_relative_path(connection, project_id) epsg = await get_surface_crs_epsg(connection, project_id, 0) + surface_params = await get_surface_confirmation_params(connection, str(project_id)) + project_root = Path(resolve_stored_project_path(stored_path)) route_file = find_planned_route_file(_route_input_dir(stored_path)) if route_file is None: return JSONResponse( status_code=404, content={"status": "error", "message": "계획 노선 파일이 업로드되지 않았습니다."}, ) - planned = read_planned_route_csv(route_file) + planned = await asyncio.to_thread(load_design_route, project_root, surface_params) if planned is None or len(planned.vertices) < 2: return JSONResponse( status_code=400, content={ "status": "error", - "message": f"계획 노선 파일을 읽지 못했습니다: {route_file.name}", + "message": f"계획 노선을 읽지 못했거나 지표면과 겹치지 않습니다: {route_file.name}", }, ) - # 노선 파일이 CRS를 명시하면 그 값을 따른다. 도엽 재투영도 같은 좌표계로 맞춘다. - source_crs = f"EPSG:{planned.epsg or epsg or 5186}" + # 설계 계통과 같은 프로젝트 좌표계로 맞춘다. 도엽 재투영도 이 좌표계로 간다. + source_crs = planned.crs_input or f"EPSG:{epsg or 5186}" to_lonlat_transformer = Transformer.from_crs(source_crs, "EPSG:4326", always_xy=True) to_metric_transformer = Transformer.from_crs("EPSG:4326", source_crs, always_xy=True) @@ -246,7 +253,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/B04_PreProcess/B04_PreProcess_UI_Page.ts b/B04_PreProcess/B04_PreProcess_UI_Page.ts index 9b730325..41781d40 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Page.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Page.ts @@ -26,6 +26,7 @@ import { analyzeSurface, confirmSurfaceModel, fetchConfirmedSurface, + fetchPlannedRoute, fetchSurfacePointCloud, fetchSurfaceStatus, listSurfaceInputFiles, @@ -528,6 +529,14 @@ export async function renderB04Surface(root: HTMLElement): Promise { terrainViewer.setContourInterval(confirmed.contour_interval_m); renderInputFiles(inputs.files); renderStatus(status); + // 3D 좌표 환산 기준은 확정 서피스에서 먼저 받는다(수 KB). 포인트클라우드는 수십 MB라 + // 늦거나 실패할 수 있는데, 그때 노선까지 같이 사라지면 안 된다. + if (confirmed.bounds) terrainViewer.setReferenceBounds(confirmed.bounds); + // 계획노선을 3D 최고 표고 평면에도 얹는다 — 라이다가 노선을 어디까지 덮는지 + // 평면상으로 바로 보인다(2026-09-01 사용자 지시). + void fetchPlannedRoute(projectId) + .then((route) => terrainViewer.setRoute(route.points ?? [])) + .catch(() => terrainViewer.setRoute([])); viewer.setLoading("포인트 데이터 로딩 중…"); try { pointCloud = await fetchSurfacePointCloud( diff --git a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts index 8f1d8fa3..39819486 100644 --- a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts +++ b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts @@ -6,6 +6,8 @@ import { API_BASE_URL } from "@config/config_frontend"; import { fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createProgressCircle } from "@ui/ui_template_progress"; +// 계획선 색은 2D 지도·B05 배수유역도와 한 곳에서 나온다 — 같은 선을 다른 색으로 그리지 않는다. +import { routeLineColor } from "./B04_PreProcess_UI_MapRender"; import type { SurfaceBounds, SurfaceModelSummary, @@ -37,6 +39,8 @@ export interface SurfaceTerrainViewer { smoothingField: HTMLElement; render: (projectId: string, models: readonly SurfaceModelSummary[]) => void; setReferenceBounds: (bounds: SurfaceBounds) => void; + /** 계획노선(사업지 좌표계 m)을 3D 최고 표고 평면에 그린다. 빈 목록이면 걷어낸다. */ + setRoute: (points: ReadonlyArray<{ x: number; y: number }>) => void; setSelection: (sourceFilter: string, method: string) => void; /** 다른 모델(예: 라이다 지표면)을 반투명으로 겹쳐 본다. 빈 문자열이면 걷어낸다. */ showOverlay: ( @@ -265,6 +269,13 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { contourGroup.visible = contourCheck.checked; scene.add(contourGroup); + // 계획노선 — 지표면에 드리우지 않고 **데이터 최고 표고 평면**에 수평으로 얹는다 + // (2026-09-01 사용자 확정). 노선과 측량 범위가 평면상 어디서 어긋나는지 보려는 것이라 + // 지형을 따라 오르내리면 오히려 판단이 어렵다. + const routeGroup = new THREE.Group(); + scene.add(routeGroup); + let routePoints: ReadonlyArray<{ x: number; y: number }> = []; + let terrainMesh: THREE.Object3D | null = null; const labelElements: HTMLDivElement[] = []; // 라벨 목록이 바뀌거나 표시 옵션을 껐다 켰을 때는 카메라가 그대로여도 다시 배치해야 한다. @@ -382,8 +393,39 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { legendBar.style.display = "none"; } + function drawRoute(): void { + while (routeGroup.children.length > 0) { + const child = routeGroup.children[0]; + routeGroup.remove(child); + if (child instanceof THREE.Line) { + child.geometry.dispose(); + (child.material as THREE.Material).dispose(); + } + } + if (routePoints.length < 2 || !referenceBounds) return; + // 뷰어 좌표 규약은 백엔드 scene_vertices와 같다: x, 높이, -y. + const cx = (referenceBounds.x_min + referenceBounds.x_max) / 2; + const cy = (referenceBounds.y_min + referenceBounds.y_max) / 2; + const cz = (referenceBounds.z_min + referenceBounds.z_max) / 2; + const planeY = referenceBounds.z_max - cz; + const vertices = routePoints.map( + (point) => new THREE.Vector3(point.x - cx, planeY, -(point.y - cy)), + ); + const material = new THREE.LineBasicMaterial({ + color: new THREE.Color(routeLineColor()), + }); + routeGroup.add( + new THREE.Line(new THREE.BufferGeometry().setFromPoints(vertices), material), + ); + // 노선은 지형 로딩과 따로 도착한다. 지형이 이미 떠 있으면 노선까지 담도록 다시 맞춘다. + if (terrainMesh) fitCamera(terrainMesh); + } + const getFitParams = (object: THREE.Object3D) => { const box = new THREE.Box3().setFromObject(object); + // 계획노선은 최고 표고 평면에 있어 지형 상자 위·밖으로 걸친다. 지형만 보고 맞추면 + // 노선이 화면 밖으로 밀려 보이지 않는다 — 프레임에 같이 넣는다. + if (routeGroup.children.length > 0) box.expandByObject(routeGroup); const center = box.getCenter(new THREE.Vector3()); const size = box.getSize(new THREE.Vector3()); const span = Math.max(size.x, size.y, size.z, 1); @@ -846,6 +888,11 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { }, setReferenceBounds(bounds) { referenceBounds = bounds; + drawRoute(); + }, + setRoute(points) { + routePoints = points; + drawRoute(); }, setSelection(sourceFilter, method) { activeFilter = sourceFilter; 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..364704b7 100644 --- a/common_util/common_util_drainage_context.py +++ b/common_util/common_util_drainage_context.py @@ -30,8 +30,7 @@ from B06_Section.B06_Section_Repository import get_longitudinal_section from common_util.common_util_route_geometry import ( RouteVertex, build_route_vertices, - find_planned_route_file, - read_planned_route_csv, + load_design_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 @@ -82,9 +81,11 @@ async def load_drainage_context(project_id: UUID) -> tuple[DrainageContext | Non surface_params = await get_surface_confirmation_params(connection, str(project_id)) project_root = Path(resolve_stored_project_path(stored_path)) - planned = await asyncio.to_thread(_read_planned_route, project_root) + # 설계 계통과 **같은 노선**을 쓴다 — 지표면 밖 구간을 자른 뒤의 노선이다. 원본을 그대로 + # 쓰면 유역·관이 확정 노선 밖에도 찍혀 종단 계획선이 그 관을 버린다(2026-09-01). + planned = await asyncio.to_thread(_read_planned_route, project_root, surface_params) if planned is None or len(planned.vertices) < 2: - return None, "원청 계획노선 CSV를 읽지 못했습니다. B03에서 노선 파일을 확인하세요." + return None, "계획노선을 읽지 못했습니다. B03에서 노선 파일을 확인하세요." sampler = await asyncio.to_thread(_open_sampler, project_root, surface_params) vertices, z_source = await asyncio.to_thread( @@ -111,10 +112,9 @@ 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 +def _read_planned_route(project_root: Path, surface_params: dict[str, Any] | None = None): + """설계용 계획노선을 읽는다(파일 접근이라 스레드에서 돈다).""" + return load_design_route(project_root, surface_params) def _open_sampler(project_root: Path, surface_params: dict[str, Any]): diff --git a/common_util/common_util_drainage_detail.py b/common_util/common_util_drainage_detail.py index 33534c38..ceb52b29 100644 --- a/common_util/common_util_drainage_detail.py +++ b/common_util/common_util_drainage_detail.py @@ -55,11 +55,11 @@ from config.config_system import ( DRAINAGE_DITCH_SAMPLE_M, DRAINAGE_FLOW_AREA_RATIO, DRAINAGE_MANNING_N, - DRAINAGE_RECOMMEND_DIAMETERS_MM, DRAINAGE_PIPE_MAX_SPACING_M, DRAINAGE_PIPE_MIN_SPACING_M, DRAINAGE_PIPE_SLOPE_DEG, DRAINAGE_RAINFALL_FILENAME, + DRAINAGE_RECOMMEND_DIAMETERS_MM, DRAINAGE_RUNOFF_COEFFICIENT, DRAINAGE_TC_MIN_MINUTES, DRAINAGE_VELOCITY_MAX_MS, diff --git a/common_util/common_util_route_geometry.py b/common_util/common_util_route_geometry.py index 22913bcd..3dfd7032 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,12 +14,15 @@ from __future__ import annotations import csv import logging import math +import struct from dataclasses import dataclass from pathlib import Path from typing import Any from shapely.geometry import LineString, Point, shape +from config.config_system import SURFACE_ROUTE_EDGE_TRIM_M + logger = logging.getLogger(__name__) # 계획 노선 CSV 열 이름 후보. B03이 여러 형식을 받게 되므로 흔한 표기를 모두 받아 준다. @@ -60,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: @@ -109,17 +116,289 @@ 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 + 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 load_design_route( + project_root: Path, surface_params: dict[str, Any] | None = None +) -> PlannedRoute | None: + """설계가 쓸 계획노선 한 벌을 만든다 — 읽기·좌표계 변환·트림·조밀화를 여기서 끝낸다. + + 노선을 읽는 곳이 여럿이라(체인·배수유역·유입·도엽) 각자 읽으면 트림이 적용된 곳과 + 안 된 곳이 갈린다. 실제로 그렇게 갈려 관 측점이 트림 전(2,136m) 기준으로 찍히고 + 확정 노선(1,070m)과 어긋나 종단 계획선이 직선으로 나왔다(2026-09-01). + **설계 계통은 전부 이 함수를 지난다.** + + `surface_params`(확정 필터·방식·스무딩)를 주면 지표면이 덮지 못하는 구간을 잘라 내고, + B05 격자 탐색이 계획노선을 바꾸지 않도록 정점 간격을 직결 문턱 아래로 좁힌다. + 주지 않으면 읽어서 좌표계만 맞춘 원본을 돌려준다. + """ + from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj + from config.config_system import ( + ROUTE_DIRECT_LINK_CELL_FACTOR, + ROUTE_GRID_RES_M, + ROUTE_PLANNED_DENSIFY_SAFETY, ) - return candidates[0] if candidates else None + + route_file = find_planned_route_file(project_root / "B03_FileInput" / "input") + planned = read_planned_route(route_file) if route_file else None + if planned is None or len(planned.vertices) < 2: + return None + + target_crs = project_epsg_from_prj(project_root) + points = [(float(v.x), float(v.y)) for v in planned.vertices] + source_crs = planned.crs_input or target_crs + if source_crs.upper() != target_crs.upper(): + from pyproj import Transformer + + transformer = Transformer.from_crs(source_crs, target_crs, always_xy=True) + points = [transformer.transform(x, y) for x, y in points] + + if surface_params: + from common_util.common_util_surface_sampler import build_surface_sampler + + try: + sampler = build_surface_sampler( + project_root / "B04_PreProcess" / "models", + str(surface_params["source_filter"]), + str(surface_params["method"]), + bool(surface_params["smooth"]), + ) + except (FileNotFoundError, KeyError, OSError) as exc: + logger.warning("설계 노선: 지표면을 열지 못해 트림을 건너뜁니다 — %s", exc) + else: + points = trim_route_to_surface(points, sampler) + points = densify_route( + points, + ROUTE_DIRECT_LINK_CELL_FACTOR * ROUTE_GRID_RES_M * ROUTE_PLANNED_DENSIFY_SAFETY, + ) + if len(points) < 2: + return None + return replace_vertices(planned, points, crs_input=target_crs) + + +def replace_vertices( + planned: PlannedRoute, points: list[tuple[float, float]], *, crs_input: str | None = None +) -> PlannedRoute: + """XY 목록으로 노선 정점을 갈아 끼우고 누가거리를 다시 센다.""" + vertices: list[RouteVertex] = [] + cumulative = 0.0 + previous: tuple[float, float] | None = None + for x, y in points: + if previous is not None: + cumulative += math.dist(previous, (x, y)) + vertices.append(RouteVertex(x=float(x), y=float(y), z=0.0, chainage_m=cumulative)) + previous = (x, y) + return PlannedRoute( + vertices=vertices, + epsg=planned.epsg, + name=planned.name, + source=planned.source, + crs_input=crs_input or planned.crs_input, + ) + + +def trim_route_to_surface( + points: list[tuple[float, float]], + sampler: Any, + edge_trim_m: float = SURFACE_ROUTE_EDGE_TRIM_M, +) -> list[tuple[float, float]]: + """지표면이 덮는 구간만 남기고 계획노선을 자른다. + + 판정은 sampler가 돌려주는 `valid` — 확정 DTM의 valid_mask가 곧 불규칙한 실제 + 외곽이다(bounds 사각형이 아니다). 가장 긴 연속 유효 구간을 남긴다. + + `edge_trim_m`은 **잘라 낸 쪽 끝에만** 적용한다. 서피스 가장자리는 점 밀도가 떨어져 + 지반고가 못 미덥기 때문이다. 노선 본래 끝점이 서피스 안이면 깎지 않는다 — 멀쩡한 + 구간을 짧게 만들 이유가 없다 (2026-09-01 사용자 확정). + + 전부 유효하면 입력을 그대로 돌려준다. 남는 구간이 2점 미만이면 빈 목록. + """ + import numpy as np + + if len(points) < 2: + return list(points) + xy = np.asarray(points, dtype=np.float64) + try: + _, valid = sampler.sample_xy(xy) + except (ValueError, OSError) as exc: + logger.warning("노선 트림: 지표면 샘플링 실패 — %s", exc) + return list(points) + valid = np.asarray(valid, dtype=bool) + if valid.all(): + return list(points) + if not valid.any(): + logger.warning("노선 트림: 노선 전체가 지표면 밖입니다.") + return [] + + # 가장 긴 연속 유효 구간 — 가장자리에서 한두 점이 튀어도 본 구간을 잃지 않는다. + best_start = best_end = start = -1 + for index, ok in enumerate([*valid.tolist(), False]): + if ok and start < 0: + start = index + elif not ok and start >= 0: + if index - start > best_end - best_start: + best_start, best_end = start, index + start = -1 + kept = [(float(x), float(y)) for x, y in xy[best_start:best_end]] + + trim_head = best_start > 0 + trim_tail = best_end < len(xy) + kept = _trim_ends(kept, edge_trim_m if trim_head else 0.0, edge_trim_m if trim_tail else 0.0) + logger.info( + "노선 트림: 정점 %d개 → %d개 (지표면 밖 %d개, 가장자리 여유 %.0fm %s)", + len(points), + len(kept), + int((~valid).sum()), + edge_trim_m, + "앞뒤" if trim_head and trim_tail else ("앞" if trim_head else "뒤"), + ) + return kept + + +def densify_route( + points: list[tuple[float, float]], max_spacing_m: float +) -> list[tuple[float, float]]: + """원래 정점은 모두 남기고, 간격이 `max_spacing_m`을 넘는 구간에만 점을 끼워 넣는다. + + **평면 형상은 바뀌지 않는다** — 같은 직선 위에 점을 더 찍을 뿐이다. + B05 경로 탐색은 제어점 간격이 `ROUTE_DIRECT_LINK_CELL_FACTOR × ROUTE_GRID_RES_M` + 이하일 때만 격자 탐색을 건너뛰고 원좌표를 그대로 잇는다 + (`B05_Profile_Engine_Solver.py:394`). 예정노선처럼 정점이 성긴 선(용화 평균 17.6m)은 + 그 문턱을 넘어 탐색을 타고, 급경사 현에서 "통과 경로 없음"으로 끊긴다. + 간격만 좁혀 주면 계획노선이 손대지 않은 채 그대로 채택된다 (2026-09-01 사용자 확정). + """ + if len(points) < 2 or max_spacing_m <= 0: + return list(points) + dense: list[tuple[float, float]] = [points[0]] + for start, end in zip(points, points[1:]): + distance = math.dist(start, end) + steps = int(math.ceil(distance / max_spacing_m)) + for step in range(1, steps): + ratio = step / steps + dense.append( + ( + start[0] + (end[0] - start[0]) * ratio, + start[1] + (end[1] - start[1]) * ratio, + ) + ) + dense.append(end) + return dense + + +def _trim_ends( + points: list[tuple[float, float]], head_m: float, tail_m: float +) -> list[tuple[float, float]]: + """폴리라인 앞뒤에서 지정 길이만큼 잘라 낸다. 남는 게 2점 미만이면 빈 목록.""" + if len(points) < 2 or (head_m <= 0 and tail_m <= 0): + return points + line = LineString(points) + start = min(head_m, line.length) + end = max(start, line.length - tail_m) + if end - start <= 0: + logger.warning("노선 트림: 여유를 깎고 나니 남는 구간이 없습니다.") + return [] + cumulative = 0.0 + kept: list[tuple[float, float]] = [line.interpolate(start).coords[0]] + for index in range(1, len(points)): + cumulative += math.dist(points[index - 1], points[index]) + if start < cumulative < end: + kept.append(points[index]) + kept.append(line.interpolate(end).coords[0]) + return kept if len(kept) >= 2 else [] def build_route_vertices(points: list[dict[str, Any]]) -> list[RouteVertex]: 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 a0673e08..794fa3a8 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")) @@ -137,6 +154,11 @@ SURFACE_CLASSIFIED_GROUND_MIN_RATIO = float( # 지면점 비율이 이 값 미만이면 필터가 사실상 실패한 것으로 보고 WARNING을 남긴다. SURFACE_GROUND_RATIO_WARN = float(os.getenv("SURFACE_GROUND_RATIO_WARN", "0.01")) +# 계획노선이 지표면 밖으로 나가 잘릴 때, 잘린 쪽 끝에서 더 깎을 길이(m). +# 서피스 가장자리는 점 밀도가 떨어져 외곽선이 불규칙하다 — 경계에 딱 붙여 자르면 +# 그 구간 지반고가 못 미덥다 (2026-09-01 사용자 확정). +SURFACE_ROUTE_EDGE_TRIM_M = float(os.getenv("SURFACE_ROUTE_EDGE_TRIM_M", "30.0")) + # ───────────────────────────────────────────────────────────────────────── # 5-2. 지표면 모델 생성 파라미터 (TIN/DTM/NURBS/implicit/meshfree) # ───────────────────────────────────────────────────────────────────────── @@ -216,6 +238,14 @@ SHEET_SURFACE_METHODS = [ ] # 확정에 쓸 기본 방식 — 라플라스 (2026-08-30 사용자 확정). SHEET_SURFACE_DEFAULT_METHOD = os.getenv("SHEET_SURFACE_DEFAULT_METHOD", "laplace") +# 자동 전처리가 만드는 도엽 방식 — 기본 하나뿐이다. 여섯 방식을 매번 다 만들면 +# WF1의 대부분(용화 실측 891초 중 655초)을 여기서 쓴다. 나머지는 관리자가 B04에서 +# 그 방식을 고를 때 만든다 (2026-09-01 사용자 확정). +SHEET_SURFACE_AUTO_METHODS = [ + method.strip() + for method in os.getenv("SHEET_SURFACE_AUTO_METHODS", SHEET_SURFACE_DEFAULT_METHOD).split(",") + if method.strip() +] # 일반 사용자 WF1 자동 확정 기본값 SURFACE_CONFIRM_DEFAULT_FILTER = os.getenv("SURFACE_CONFIRM_DEFAULT_FILTER", "csf") @@ -280,6 +310,9 @@ ROUTE_REQUIRED_POINT_TOLERANCE_M = float(os.getenv("ROUTE_REQUIRED_POINT_TOLERAN # 제어점 쌍이 이 배수×비용면 셀보다 가까우면 격자 탐색 없이 직결한다 — 원청 계획노선 # 보존 (2026-08-30 사용자 확정. 조밀 기준선은 격자 중간점이 못 끼어들어 평면 불변). ROUTE_DIRECT_LINK_CELL_FACTOR = float(os.getenv("ROUTE_DIRECT_LINK_CELL_FACTOR", "2.0")) +# 예정노선을 자동 체인에 넘기기 전, 직결 문턱의 이 비율까지 정점 간격을 좁힌다. +# 문턱과 정확히 같게 두면 부동소수 오차 한 번에 탐색으로 넘어가 노선이 바뀐다. +ROUTE_PLANNED_DENSIFY_SAFETY = float(os.getenv("ROUTE_PLANNED_DENSIFY_SAFETY", "0.9")) # 임도 종류 — 현행 규칙(별표2)의 3종. `branch`(지선)는 규칙에서 폐지됐으나 기존 # 저장분이 남아 있어 값으로는 계속 받는다(화면 선택지에서는 뺀다, 2026-08-19). ROUTE_GRADE_CLASSES = ("trunk", "fire", "work", "branch") 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: [ "선택한 파일 유형이 이 카드와 맞지 않습니다.",