From 8a0d640e0e4312a0957da6f93aeddbb672f61041 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 31 Jul 2026 20:57:09 +0900 Subject: [PATCH] =?UTF-8?q?refactor(B04/B05):=20=EB=B0=B0=EC=88=98?= =?UTF-8?q?=EC=9C=A0=EC=97=AD=20=EB=B6=84=EC=84=9D=EC=9D=84=20B04=EB=A1=9C?= =?UTF-8?q?=20=EC=9D=B4=EA=B4=80,=20B05=EB=8A=94=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EB=B6=84=20=EC=86=8C=EB=B9=84=EB=A7=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 분석이 30초 걸리는데 B05는 일반 사용자 화면이다. 관리자 확인용 B04에서 한 번 돌려 저장하고, B05는 그 결과를 읽어 관 보충과 세부유역만 처리한다 (2026-07-31 사용자 지시). 노선 원천 변경 - B05 확정 경로 -> B03 업로드 계획 노선 파일(CSV). 분석이 노선 설계보다 먼저 끝나 있어야 하기 때문. 샘플 planned_route_sample_epsg5187.csv 로 검증. - common_util_route_geometry.py 신설 — RouteVertex/StructureCandidate/누가거리 보간/세류 교차점/계획 노선 CSV 리더. B04와 B05가 같은 표현을 쓰도록 공용화. 열 이름은 대소문자·한글 표기를 함께 받는다(B03이 여러 형식 수용 예정). B04 (관리자 확인용, 신규) - Engine_Watershed_{Grid,Stream,Descent,Flow,Expand,Export} — B05에서 git mv - Engine_Watershed_Analyze.py — 1~8단계 오케스트레이션 - Router_Watershed.py — GET /drainage/primary-region - UI_Watershed.ts — 2D 지도 GIS 레이어 그룹에 "배수유역" 토글 추가. 격자/화살표/세류망/1차영역/2차유역/기본관을 겹쳐 그린다. - 저장 위치 B05_wf2_Route/drainage -> B04_wf1_Surface/drainage - 03_road_routing 단계 추가: B05가 세부유역을 나눌 최소 배열(셀->도로셀 귀속, 유하장, 강도, 도로셀 제원, 셀 표고) + 계획도로선/기본배관/2차유역 기하 B05 (일반 사용자용, 축소) - Engine_Drainage_Basin.py — B04 산출물 로더 + 관 보충(9) + 측구 라우팅/세부유역(10,11) - Engine_Drainage.py 는 관경 산정만 남기고 322 -> 27줄 - Router_Drainage.py 509 -> 142줄. POST /drainage/basins 만 남김 - 화살표·격자·강도 띠 렌더 제거. 계획도로선/기본배관/2차유역만 받는다 삭제 - _legacy_watershed/ 4파일 (능선 행진 방식 원본 보관본) - Engine_Watershed_Basin.py (B04 Analyze + B05 Drainage_Basin 으로 분할) - GET /drainage/candidates 와 propose_structure_stations (구방식 후보 제안) E2E 검증 (실데이터) B04 분석 28.2s -> 저장(geojson 11KB + npz 2.6MB) B05 로드 + 세부 설계 0.11s <-- 30초가 0.1초로 면적 457,404m2 로 B04 2차 유역과 정확히 일치 관 편집 재산정 0.12s, 관 3개 -> 세부유역 3개, 면적 보존 Co-Authored-By: Claude Fable 5 --- B03_FileInput/B03_FileInput_Api_Fetch.ts | 7 +- B03_FileInput/B03_FileInput_Engine_Analyze.py | 112 +++ B03_FileInput/B03_FileInput_Repository.py | 25 + B03_FileInput/B03_FileInput_Router.py | 107 ++- B03_FileInput/B03_FileInput_Schema.py | 1 + B03_FileInput/B03_FileInput_UI_Page.ts | 16 +- B03_FileInput/B03_FileInput_UI_Style.css | 9 + B03_FileInput/B03_FileInput_UI_Support.ts | 9 +- .../test_B03_FileInput_Engine_Analyze.py | 58 ++ B03_FileInput/test_B03_FileInput_Router.py | 60 ++ B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts | 120 ++- ...04_wf1_Surface_Engine_Watershed_Analyze.py | 270 +++++++ ...04_wf1_Surface_Engine_Watershed_Descent.py | 2 +- ...B04_wf1_Surface_Engine_Watershed_Expand.py | 6 +- ...B04_wf1_Surface_Engine_Watershed_Export.py | 8 +- .../B04_wf1_Surface_Engine_Watershed_Flow.py | 2 +- .../B04_wf1_Surface_Engine_Watershed_Grid.py | 0 ...B04_wf1_Surface_Engine_Watershed_Stream.py | 4 +- .../B04_wf1_Surface_Router_Watershed.py | 482 ++++++++++++ .../B04_wf1_Surface_UI_MapViewer.ts | 18 +- .../B04_wf1_Surface_UI_Watershed.ts | 397 ++++++++++ B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts | 100 +-- .../B05_wf2_Route_Engine_Drainage.py | 305 +------- .../B05_wf2_Route_Engine_Drainage_Basin.py | 441 +++++++++++ .../B05_wf2_Route_Engine_Watershed_Basin.py | 723 ------------------ .../B05_wf2_Route_Router_Drainage.py | 437 +---------- .../B05_wf2_Route_UI_Drainage_Panel.ts | 342 +-------- .../B05_wf2_Route_UI_Drainage_Pipes.ts | 40 - ...B05_wf2_Route_Engine_Drainage_Watershed.py | 572 -------------- ...B05_wf2_Route_Engine_Watershed_Assemble.py | 205 ----- ...05_wf2_Route_Engine_Watershed_Subdivide.py | 171 ----- .../B05_wf2_Route_Engine_Watershed_Trace.py | 657 ---------------- B05_wf2_Route/_legacy_watershed/README.md | 16 - common_util/common_util_route_geometry.py | 255 ++++++ config/config_frontend.ts | 4 +- config/config_system.py | 2 +- main.py | 2 + ui_template/ui_template_locale.ts | 15 +- 38 files changed, 2440 insertions(+), 3560 deletions(-) create mode 100644 B03_FileInput/test_B03_FileInput_Engine_Analyze.py create mode 100644 B03_FileInput/test_B03_FileInput_Router.py create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Analyze.py rename B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Descent.py => B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Descent.py (99%) rename B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Expand.py => B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Expand.py (97%) rename B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Export.py => B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Export.py (95%) rename B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py => B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Flow.py (99%) rename B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py => B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Grid.py (100%) rename B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Stream.py => B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Stream.py (98%) create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py create mode 100644 B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts create mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py delete mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py delete mode 100644 B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Drainage_Watershed.py delete mode 100644 B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Assemble.py delete mode 100644 B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Subdivide.py delete mode 100644 B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Trace.py delete mode 100644 B05_wf2_Route/_legacy_watershed/README.md create mode 100644 common_util/common_util_route_geometry.py diff --git a/B03_FileInput/B03_FileInput_Api_Fetch.ts b/B03_FileInput/B03_FileInput_Api_Fetch.ts index 62bab955..e9664935 100644 --- a/B03_FileInput/B03_FileInput_Api_Fetch.ts +++ b/B03_FileInput/B03_FileInput_Api_Fetch.ts @@ -120,12 +120,17 @@ export async function finalizeUploadSession( projectId: string, sessionId: string, totalChunks: number, + completeUpload: boolean, ): Promise { const response = await fetch(`${API_BASE_URL}/projects/${projectId}/finalize`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ session_id: sessionId, total_chunks: totalChunks }), + body: JSON.stringify({ + session_id: sessionId, + total_chunks: totalChunks, + complete_upload: completeUpload, + }), }); return await readJsonOrThrow(response); } diff --git a/B03_FileInput/B03_FileInput_Engine_Analyze.py b/B03_FileInput/B03_FileInput_Engine_Analyze.py index 682b711b..3bed2c34 100644 --- a/B03_FileInput/B03_FileInput_Engine_Analyze.py +++ b/B03_FileInput/B03_FileInput_Engine_Analyze.py @@ -1,5 +1,6 @@ """B03 원본 입력 파일 메타데이터 분석.""" +import csv import logging import math import re @@ -269,10 +270,121 @@ def analyze_tif_metadata(path: str | Path) -> dict[str, Any]: rasterio_logger.removeFilter(warning_filter) +_PLANNED_ROUTE_COLUMNS = ("route_name", "sequence", "x", "y", "z", "crs_epsg") + + +def _parse_route_integer(value: str, *, field: str, row_number: int) -> int: + normalized = value.strip() + if not re.fullmatch(r"[0-9]+", normalized): + raise ValueError(f"CSV {row_number}행의 {field} 값은 양의 정수여야 합니다.") + parsed = int(normalized) + if parsed <= 0: + raise ValueError(f"CSV {row_number}행의 {field} 값은 양의 정수여야 합니다.") + return parsed + + +def _parse_route_coordinate(value: str, *, field: str, row_number: int) -> float: + try: + parsed = float(value.strip()) + except (AttributeError, ValueError) as exc: + raise ValueError(f"CSV {row_number}행의 {field} 값은 숫자여야 합니다.") from exc + if not math.isfinite(parsed): + raise ValueError(f"CSV {row_number}행의 {field} 값은 유한한 숫자여야 합니다.") + return parsed + + +def analyze_planned_route_csv(path: str | Path) -> dict[str, Any]: + """원청 계획노선 CSV를 검증하고 경로 메타데이터를 반환한다.""" + source = Path(path) + with source.open("r", encoding="utf-8-sig", newline="") as csv_file: + reader = csv.DictReader(csv_file) + if reader.fieldnames is None: + raise ValueError("계획노선 CSV 헤더를 찾을 수 없습니다.") + + normalized_headers = [header.strip() for header in reader.fieldnames] + if len(set(normalized_headers)) != len(normalized_headers): + raise ValueError("계획노선 CSV 헤더에 중복된 열이 있습니다.") + header_map = dict(zip(normalized_headers, reader.fieldnames, strict=True)) + missing = [column for column in _PLANNED_ROUTE_COLUMNS if column not in header_map] + if missing: + raise ValueError(f"계획노선 CSV 필수 열이 없습니다: {', '.join(missing)}") + + route_name: str | None = None + crs_epsg: int | None = None + points: list[tuple[float, float, float]] = [] + for expected_sequence, row in enumerate(reader, start=1): + row_number = expected_sequence + 1 + current_name = (row.get(header_map["route_name"]) or "").strip() + if not current_name: + raise ValueError(f"CSV {row_number}행의 route_name 값이 비어 있습니다.") + if route_name is None: + route_name = current_name + elif current_name != route_name: + raise ValueError("계획노선 CSV에는 하나의 route_name만 사용할 수 있습니다.") + + sequence = _parse_route_integer( + row.get(header_map["sequence"]) or "", + field="sequence", + row_number=row_number, + ) + if sequence != expected_sequence: + raise ValueError( + f"CSV {row_number}행의 sequence는 {expected_sequence}이어야 합니다." + ) + + current_epsg = _parse_route_integer( + row.get(header_map["crs_epsg"]) or "", + field="crs_epsg", + row_number=row_number, + ) + if crs_epsg is None: + crs_epsg = current_epsg + elif current_epsg != crs_epsg: + raise ValueError("계획노선 CSV의 crs_epsg는 모든 행에서 같아야 합니다.") + + points.append( + tuple( + _parse_route_coordinate( + row.get(header_map[field]) or "", + field=field, + row_number=row_number, + ) + for field in ("x", "y", "z") + ) + ) + + if len(points) < 2: + raise ValueError("계획노선 CSV에는 좌표가 2개 이상 있어야 합니다.") + + xs, ys, zs = zip(*points, strict=True) + return { + "file": source.name, + "extension": "csv", + "size_bytes": source.stat().st_size, + "purpose": "planned_route", + "route_name": route_name, + "point_count": len(points), + "epsg": crs_epsg, + "columns": list(_PLANNED_ROUTE_COLUMNS), + "bounds": { + "x_min": min(xs), + "x_max": max(xs), + "y_min": min(ys), + "y_max": max(ys), + "z_min": min(zs), + "z_max": max(zs), + }, + "start_point": list(points[0]), + "end_point": list(points[-1]), + } + + def analyze_input_metadata(path: str | Path) -> dict[str, Any]: """입력 파일 확장자에 맞는 B03 메타데이터 분석 함수를 호출한다.""" source = Path(path) extension = source.suffix.lower() + if extension == ".csv": + return analyze_planned_route_csv(source) if extension in {".las", ".laz"}: return analyze_las_metadata(source) if extension == ".prj": diff --git a/B03_FileInput/B03_FileInput_Repository.py b/B03_FileInput/B03_FileInput_Repository.py index 752b1d78..f9ed89a7 100644 --- a/B03_FileInput/B03_FileInput_Repository.py +++ b/B03_FileInput/B03_FileInput_Repository.py @@ -61,6 +61,31 @@ async def create_input_file( return int(input_file_id) +async def get_project_input_readiness( + connection: aiomysql.Connection, + project_id: UUID, +) -> tuple[set[str], int | None]: + """현재 업로드 파일 유형과 최신 포인트클라우드 입력 ID를 반환한다.""" + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """ + SELECT id, LOWER(file_type) AS file_type + FROM input_files + WHERE project_id = %s AND status IN ('UPLOADED', 'PROCESSED') + ORDER BY id DESC + """, + (str(project_id),), + ) + rows = await cursor.fetchall() + + file_types = {str(row["file_type"]) for row in rows if row.get("file_type")} + point_cloud_id = next( + (int(row["id"]) for row in rows if str(row.get("file_type") or "") in {"las", "laz"}), + None, + ) + return file_types, point_cloud_id + + async def get_project_storage_relative_path( connection: aiomysql.Connection, project_id: UUID, diff --git a/B03_FileInput/B03_FileInput_Router.py b/B03_FileInput/B03_FileInput_Router.py index f69d088b..afcbef4b 100644 --- a/B03_FileInput/B03_FileInput_Router.py +++ b/B03_FileInput/B03_FileInput_Router.py @@ -1,6 +1,7 @@ """B03 파일 입력 FastAPI 라우터.""" import asyncio +import json import logging from pathlib import Path from typing import Any @@ -25,6 +26,7 @@ from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata from B03_FileInput.B03_FileInput_Repository import ( create_input_file, create_upload_session, + get_project_input_readiness, get_project_storage_relative_path, get_upload_session, list_completed_chunk_indexes, @@ -60,13 +62,69 @@ from config.config_system import ( logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B03 File Input"]) +_REQUIRED_FILE_TYPES = frozenset({"csv", "prj", "tfw"}) +_POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"}) + def _total_chunks(size_bytes: int, chunk_size_bytes: int) -> int: return max(1, (size_bytes + chunk_size_bytes - 1) // chunk_size_bytes) def _is_point_cloud_result(result: UploadedFileResult) -> bool: - return result.file_type.lower() in {"las", "laz"} + return result.file_type.lower() in _POINT_CLOUD_FILE_TYPES + + +def _missing_required_file_types(file_types: set[str]) -> list[str]: + missing = sorted(_REQUIRED_FILE_TYPES - file_types) + if not file_types.intersection(_POINT_CLOUD_FILE_TYPES): + missing.append("las/laz") + return missing + + +def _require_complete_file_set(file_types: set[str]) -> None: + missing = _missing_required_file_types(file_types) + if missing: + raise ValueError(f"B03 필수 입력 파일이 없습니다: {', '.join(missing)}") + + +async def _complete_file_input_if_ready( + connection: aiomysql.Connection, + project_id: UUID, +) -> int: + file_types, point_cloud_input_id = await get_project_input_readiness(connection, project_id) + _require_complete_file_set(file_types) + if point_cloud_input_id is None: + raise ValueError("LAS 또는 LAZ 입력 파일을 찾을 수 없습니다.") + async with connection.cursor() as cursor: + await complete_stage(cursor, str(project_id), 0) + return point_cloud_input_id + + +def _write_stage_metadata( + stage_root: Path, + project_id: UUID, + results: list[UploadedFileResult], +) -> None: + metadata_path = stage_root / "metadata.json" + existing_files: list[dict[str, Any]] = [] + if metadata_path.exists(): + try: + payload = json.loads(metadata_path.read_text(encoding="utf-8")) + existing_files = list(payload.get("files") or []) + except (OSError, TypeError, ValueError): + logger.warning("B03 metadata.json을 읽지 못해 새로 작성합니다: %s", metadata_path) + + merged = { + str(item.get("relative_path") or item.get("original_filename")): item + for item in existing_files + } + for result in results: + dumped = result.model_dump() + merged[result.relative_path] = dumped + atomic_write_json( + metadata_path, + {"project_id": str(project_id), "files": list(merged.values())}, + ) def _schedule_background_task(coro: Any, *, task_name: str) -> None: @@ -170,11 +228,31 @@ async def upload_project_files( "message": "LAS 또는 LAZ 파일을 정확히 1개 포함해야 합니다.", }, ) + csv_count = sum(Path(filename).suffix.lower() == ".csv" for filename in filenames) + if csv_count != 1: + return JSONResponse( + status_code=400, + content={ + "status": "error", + "message": "계획노선 CSV 파일을 정확히 1개 포함해야 합니다.", + }, + ) + request_file_types = {Path(filename).suffix.lower().lstrip(".") for filename in filenames} + missing_required = _missing_required_file_types(request_file_types) + if missing_required: + return JSONResponse( + status_code=400, + content={ + "status": "error", + "message": f"B03 필수 입력 파일이 없습니다: {', '.join(missing_required)}", + }, + ) pool = get_db_pool() saved_paths: list[Path] = [] try: results: list[UploadedFileResult] = [] + point_cloud_input_id: int | None = None async with pool.acquire() as connection: stored_path = await get_project_storage_relative_path(connection, project_id) @@ -219,18 +297,14 @@ async def upload_project_files( metadata=metadata, ) ) - async with connection.cursor() as cursor: - await complete_stage(cursor, str(project_id), 0) + point_cloud_input_id = await _complete_file_input_if_ready(connection, project_id) await connection.commit() except Exception: await connection.rollback() raise stage_root = project_root / "B03_FileInput" - atomic_write_json( - stage_root / "metadata.json", - {"project_id": str(project_id), "files": [result.model_dump() for result in results]}, - ) + _write_stage_metadata(stage_root, project_id, results) workflow_path = project_root / "workflow.json" if not workflow_path.exists(): atomic_write_json(workflow_path, load_project_workflow(project_root)) @@ -246,10 +320,11 @@ async def upload_project_files( ), task_name=f"b03-upload-email-{project_id}", ) + if point_cloud_input_id is not None: _schedule_background_task( trigger_wf1_analysis_and_email( project_id=project_id, - input_file_id=point_cloud_result.input_file_id, + input_file_id=point_cloud_input_id, user_role=str(session["role"]), ), task_name=f"b04-wf1-auto-{project_id}", @@ -390,6 +465,7 @@ async def finalize_project_upload( """청크 업로드를 최종 병합하고 input_files 메타데이터를 기록한다.""" pool = get_db_pool() final_path: Path | None = None + point_cloud_input_id: int | None = None try: async with pool.acquire() as connection: session = await get_upload_session( @@ -444,8 +520,11 @@ async def finalize_project_upload( metadata=metadata, ) await mark_upload_session_completed(connection, session_id=payload.session_id) - async with connection.cursor() as cursor: - await complete_stage(cursor, str(project_id), 0) + if payload.complete_upload: + point_cloud_input_id = await _complete_file_input_if_ready( + connection, + project_id, + ) await connection.commit() except Exception: await connection.rollback() @@ -462,10 +541,7 @@ async def finalize_project_upload( metadata=metadata, ) stage_root = project_root / "B03_FileInput" - atomic_write_json( - stage_root / "metadata.json", - {"project_id": str(project_id), "files": [result.model_dump()]}, - ) + _write_stage_metadata(stage_root, project_id, [result]) if _is_point_cloud_result(result): _schedule_background_task( _send_upload_complete_notification( @@ -474,10 +550,11 @@ async def finalize_project_upload( ), task_name=f"b03-upload-email-{project_id}", ) + if point_cloud_input_id is not None: _schedule_background_task( trigger_wf1_analysis_and_email( project_id=project_id, - input_file_id=result.input_file_id, + input_file_id=point_cloud_input_id, user_role=str(session["role"]), ), task_name=f"b04-wf1-auto-{project_id}", diff --git a/B03_FileInput/B03_FileInput_Schema.py b/B03_FileInput/B03_FileInput_Schema.py index 32247939..e1338472 100644 --- a/B03_FileInput/B03_FileInput_Schema.py +++ b/B03_FileInput/B03_FileInput_Schema.py @@ -89,6 +89,7 @@ class UploadFinalizeRequest(BaseModel): session_id: str = Field(min_length=1, max_length=36) total_chunks: int = Field(gt=0) + complete_upload: bool = True class UploadStatusResponse(BaseModel): diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index 7b618c41..140360eb 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -368,6 +368,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { async function uploadOneFile( projectId: string, state: FileSlotState, + completeUpload: boolean, ): Promise { const file = state.file; if (!file) return []; @@ -421,7 +422,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { } } - const response = await finalizeUploadSession(projectId, session, totalChunks); + const response = await finalizeUploadSession(projectId, session, totalChunks, completeUpload); localStorage.removeItem(storageKey); saveB03UploadedFile(projectId, { slot: state.slot, @@ -470,8 +471,11 @@ export async function renderB03FileInput(root: HTMLElement): Promise { pageError.textContent = ""; const uploaded: UploadedFileResult[] = []; try { - for (const state of targetStates) { - uploaded.push(...(await uploadOneFile(activeProjectId, state))); + for (let index = 0; index < targetStates.length; index += 1) { + const state = targetStates[index]; + uploaded.push( + ...(await uploadOneFile(activeProjectId, state, index === targetStates.length - 1)), + ); } renderUploadResults(uploaded); showToast(L("B03_File_Upload_Success"), "success"); @@ -544,11 +548,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise { uploadControlPanel.className = "b03-file__control-panel"; uploadControlPanel.append(subtitle, dropzone, resumeBanner, pageError, uploadButton, resultList); - const filesGroup = createCardGroup("", ["las_laz", "prj", "tfw", "tif"]); // 타이틀 공백으로 전달 + const routeGroup = createCardGroup(L("B03_File_Group_Route"), ["csv"]); + routeGroup.classList.add("b03-file__group--route"); + const filesGroup = createCardGroup(L("B03_File_Group_Terrain"), ["las_laz", "prj", "tfw", "tif"]); const cardsContainer = document.createElement("div"); cardsContainer.className = "b03-file__control-panel b03-file__cards-container-panel"; - cardsContainer.append(filesGroup); + cardsContainer.append(routeGroup, filesGroup); 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 2726521f..9ac5af96 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -136,6 +136,15 @@ gap: var(--spacing-24); } +.b03-file__group--route .b03-file__group-content { + grid-template-columns: 1fr; +} + +.b03-file__group--route .b03-file__card { + border-color: var(--color-royal-amethyst, #3e0079); + background: var(--color-mist-violet, #edecff); +} + /* Wiza 8px radius 카드 */ .b03-file__card { min-height: 220px; diff --git a/B03_FileInput/B03_FileInput_UI_Support.ts b/B03_FileInput/B03_FileInput_UI_Support.ts index 40daa173..ad2a509c 100644 --- a/B03_FileInput/B03_FileInput_UI_Support.ts +++ b/B03_FileInput/B03_FileInput_UI_Support.ts @@ -1,6 +1,6 @@ import { ui_locales } from "@ui/ui_template_locale"; -export type FileSlot = "las_laz" | "prj" | "tfw" | "tif" | "dxf"; +export type FileSlot = "csv" | "las_laz" | "prj" | "tfw" | "tif" | "dxf"; export type UploadStatus = "pending" | "uploading" | "completed" | "failed"; export interface SlotConfig { @@ -35,6 +35,13 @@ export interface StoredUploadSession { } const SLOT_CONFIGS: readonly SlotConfig[] = [ + { + slot: "csv", + labelKey: "B03_File_Slot_PlannedRoute", + icon: "⌁", + extensions: [".csv"], + isRequired: true, + }, { slot: "las_laz", labelKey: "B03_File_Slot_PointCloud", diff --git a/B03_FileInput/test_B03_FileInput_Engine_Analyze.py b/B03_FileInput/test_B03_FileInput_Engine_Analyze.py new file mode 100644 index 00000000..39dfa9d3 --- /dev/null +++ b/B03_FileInput/test_B03_FileInput_Engine_Analyze.py @@ -0,0 +1,58 @@ +import tempfile +import unittest +from pathlib import Path + +from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_planned_route_csv + + +class PlannedRouteCsvTest(unittest.TestCase): + def analyze(self, content: str) -> dict: + with tempfile.TemporaryDirectory() as temporary_dir: + path = Path(temporary_dir) / "planned_route.csv" + path.write_text(content, encoding="utf-8") + return analyze_planned_route_csv(path) + + def test_valid_route_returns_metadata(self) -> None: + metadata = self.analyze( + "route_name,sequence,x,y,z,crs_epsg\n" + "sample,1,183493.5,489290.335,544.659,5187\n" + "sample,2,183500.0,489300.0,545.0,5187\n" + ) + + self.assertEqual(metadata["purpose"], "planned_route") + self.assertEqual(metadata["route_name"], "sample") + self.assertEqual(metadata["point_count"], 2) + self.assertEqual(metadata["epsg"], 5187) + self.assertEqual(metadata["start_point"], [183493.5, 489290.335, 544.659]) + + def test_missing_header_is_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "필수 열"): + self.analyze("route_name,sequence,x,y,crs_epsg\nsample,1,183493.5,489290.335,5187\n") + + def test_non_numeric_coordinate_is_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "x 값은 숫자"): + self.analyze( + "route_name,sequence,x,y,z,crs_epsg\n" + "sample,1,not-a-number,489290.335,544.659,5187\n" + "sample,2,183500.0,489300.0,545.0,5187\n" + ) + + def test_non_contiguous_sequence_is_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "sequence는 2"): + self.analyze( + "route_name,sequence,x,y,z,crs_epsg\n" + "sample,1,183493.5,489290.335,544.659,5187\n" + "sample,3,183500.0,489300.0,545.0,5187\n" + ) + + def test_mixed_epsg_is_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "모든 행에서 같아야"): + self.analyze( + "route_name,sequence,x,y,z,crs_epsg\n" + "sample,1,183493.5,489290.335,544.659,5187\n" + "sample,2,183500.0,489300.0,545.0,5186\n" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/B03_FileInput/test_B03_FileInput_Router.py b/B03_FileInput/test_B03_FileInput_Router.py new file mode 100644 index 00000000..84f7671b --- /dev/null +++ b/B03_FileInput/test_B03_FileInput_Router.py @@ -0,0 +1,60 @@ +import json +import tempfile +import unittest +from pathlib import Path +from uuid import UUID + +from B03_FileInput.B03_FileInput_Router import ( + _missing_required_file_types, + _write_stage_metadata, +) +from B03_FileInput.B03_FileInput_Schema import UploadedFileResult + + +class B03RouterHelperTest(unittest.TestCase): + def test_required_file_types_include_planned_route(self) -> None: + self.assertEqual( + _missing_required_file_types({"las", "prj", "tfw"}), + ["csv"], + ) + self.assertEqual( + _missing_required_file_types({"csv", "laz", "prj", "tfw"}), + [], + ) + + def test_stage_metadata_preserves_existing_files(self) -> None: + project_id = UUID("acb9170b-9ac8-49b3-82a0-51cfa32bb42d") + with tempfile.TemporaryDirectory() as temporary_dir: + stage_root = Path(temporary_dir) + (stage_root / "metadata.json").write_text( + json.dumps( + { + "project_id": str(project_id), + "files": [ + { + "original_filename": "terrain.las", + "relative_path": "B03_FileInput/input/las/terrain.las", + } + ], + } + ), + encoding="utf-8", + ) + route = UploadedFileResult( + input_file_id=100, + original_filename="planned_route.csv", + file_type="csv", + relative_path="B03_FileInput/input/csv/planned_route.csv", + size_bytes=1000, + metadata={"purpose": "planned_route", "epsg": 5187}, + ) + + _write_stage_metadata(stage_root, project_id, [route]) + + payload = json.loads((stage_root / "metadata.json").read_text(encoding="utf-8")) + self.assertEqual(len(payload["files"]), 2) + self.assertEqual(payload["files"][1]["metadata"]["purpose"], "planned_route") + + +if __name__ == "__main__": + unittest.main() diff --git a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts index edcd77f8..7650e13f 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts @@ -11,7 +11,7 @@ * - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환. * ========================================================================== */ -import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; +import { API_ANALYSIS_TIMEOUT_MS, API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; /** 지표면 분석 실행 요청 (SurfaceAnalyzeRequest) */ export interface SurfaceAnalyzeRequest { @@ -111,10 +111,17 @@ export interface SurfaceModelListResponse { models: SurfaceModelSummary[]; } -/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */ -async function requestJson(path: string, init: RequestInit): Promise { +/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. + * + * `timeoutMs`를 주면 그 값으로 끊는다. 배수유역 격자 해석처럼 수십 초가 걸리는 요청은 + * `API_ANALYSIS_TIMEOUT_MS`를 넘긴다 — 기본값으로 두면 계산 도중 abort 된다. */ +async function requestJson( + path: string, + init: RequestInit, + timeoutMs: number = API_TIMEOUT_MS, +): Promise { const controller = new AbortController(); - const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); + const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(`${API_BASE_URL}${path}`, { ...init, @@ -130,6 +137,12 @@ async function requestJson(path: string, init: RequestInit): Promise { throw new Error(payload.message ?? `HTTP ${response.status}`); } return payload; + } catch (error) { + // AbortError 원문("signal is aborted without reason")은 원인을 알 수 없으니 바꿔 준다. + if (error instanceof DOMException && error.name === "AbortError") { + throw new Error(`요청이 ${Math.round(timeoutMs / 1000)}초 안에 끝나지 않았습니다.`); + } + throw error; } finally { window.clearTimeout(timeoutId); } @@ -230,3 +243,102 @@ export async function fetchGisGeoJson(projectId: string, layer: string): Promise method: "GET", }); } + +/* ── 배수유역 분석 (B04_wf1_Surface_Router_Watershed.py) ──────────────────── + * 관리자 확인용. 계획 노선(B03 CSV) + 도엽 등고선·세류선으로 유역을 끝까지 분석하고 + * 결과를 영구저장소에 남긴다. 30초 안팎이 걸리므로 여기서 한 번만 돌린다. + * ------------------------------------------------------------------------ */ + +/** 관 매설 지점 1개. reason: stream=세류 교차, spacing=간격 보충, confirmed=사용자 확정. */ +export interface WatershedPipe { + chainage_m: number; + x: number; + y: number; + lon: number; + lat: number; + reason: string; + stream_name: string | null; +} + +/** 1차 배수유역 근거(단계 검증용). TIN·흐름 계산 없이 세류 상·하류 판정과 격자 범위만 준다. */ +export interface WatershedAnalysis { + status: string; + project_id: string; + /** 분석에 쓴 계획 노선 파일명(B03 업로드). */ + route_source: string; + radius_m: number; + /** 도로와 만난 세류선의 상류측 = 1차 영역의 기준선. */ + upstream_lines: Array>; + /** 교차했으나 하류로 판정해 제외한 조각. 판정이 맞는지 눈으로 대조하는 용도. */ + downstream_lines: Array>; + /** 상·하류 어느 망에도 이어지지 않아 제외한 세류 조각 수. */ + no_contact_count: number; + /** 노선이 1차 영역 밖으로 나간 길이(m). 크면 반경을 올려야 한다는 신호. */ + road_outside_m: number; + /** 1차 영역(상류 세류망 버퍼 합집합)의 외곽 링 목록. */ + region_rings: Array>; + grid: { + cell_m: number; + rows: number; + cols: number; + /** bbox 전체 셀 수(참고값). */ + bbox_cells: number; + /** 1차 영역에 걸쳐 실제로 생성된 셀 수. */ + cells: number; + width_m: number; + height_m: number; + /** 격자 bbox 링. 화면은 이 사각형을 rows×cols로 나눠 셀 좌표를 얻는다. */ + bbox_lonlat: Array<[number, number]>; + /** 실제 생성된 셀 구간 [행, 시작열, 끝열(포함)]. 낱개 셀 대신 구간으로 온다. */ + row_spans: Array<[number, number, number]>; + }; + /** 최외곽 적색 셀 주변 확장 결과. */ + expansion: { + rounds: number; + /** 새로 추가한 셀에 적색이 없어 스스로 멈췄는가. */ + closed: boolean; + added_cells: number; + /** 확장 전(1차 영역) 셀 수. */ + initial_cells: number; + }; + /** 셀별 흐름 방향과 도로 도달 여부. 등고선이 없어 판정을 못하면 null. */ + flow: { + encoding: "base64-uint8"; + /** 방위 분해능(32). 코드 0 = 화면 오른쪽, 시계방향 증가. */ + azimuth_steps: number; + /** 제자리(더 낮은 이웃 없음)를 뜻하는 코드. */ + sink_code: number; + /** 표고가 없어 판정 못한 셀 코드. */ + invalid_code: number; + cells: number; + reaches_road: number; + no_road: number; + /** 격자에는 있으나 등고선 TIN 밖이라 표고가 없어 판정 못한 셀. */ + unanalyzed: number; + /** 확정된 상류 세류망을 따라 흐름을 강제로 새긴 셀 수. */ + burned: number; + outer_seeds: number; + interior_seeds: number; + /** 셀당 1바이트. 하위 6비트=32방위 코드(32=제자리, 33=무효), 0x80=도로 도달. + * 순서는 grid.row_spans를 행 → 구간 → 열 오름차순으로 훑은 순서와 같다. */ + data: string; + } | null; + /** 2차 전체 배수유역 외곽선(= 분수령). 적색 셀 전체의 외곽. */ + basin_polygon_lonlat: Array<[number, number]>; + basin_area_m2: number; + /** 도로 위 흐름 강도 — [누가거리 m, 그 구간으로 모이는 상류 면적 ㎡]. */ + strength_profile: Array<[number, number]>; + /** 기본 관 매설 위치 — 도로 × 세류선 교차점. */ + pipes: WatershedPipe[]; + /** 영구저장소에 남긴 검증용 GeoJSON 경로. */ + saved_to: string | null; +} + +export async function fetchWatershedAnalysis(projectId: string): Promise { + // 등고선 하강 방향 + 적색 확장 루프까지 도는 요청이라 수십 초가 걸린다. + return requestJson( + `/projects/${projectId}/drainage/primary-region`, + { method: "GET" }, + API_ANALYSIS_TIMEOUT_MS, + ); +} diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Analyze.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Analyze.py new file mode 100644 index 00000000..7fcc071d --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Analyze.py @@ -0,0 +1,270 @@ +"""배수유역 분석 오케스트레이터 (B04 — 관리자 확인용 전처리). + +계획 노선(B03 업로드 파일)과 도엽 등고선·세류선만으로 배수유역을 끝까지 분석해 +영구저장소에 남긴다. 30초 안팎이 걸리는 무거운 작업이라 여기서 한 번만 돌리고, +일반 사용자가 쓰는 B05는 그 결과를 읽어 쓰기만 한다(2026-07-31 사용자 지시). + + ① 도로 교차 세류망 상류측 추출 → 반경 버퍼 = 1차 배수유역 + ② 도로 시작점 기준 격자 생성 (1차 영역에 걸치는 셀만) + ③ 등고선 하강 방향 — 높은 등고 라인에서 낮은 등고 라인으로. 보간면을 쓰지 않으므로 + 가짜 웅덩이·평탄면이 원리적으로 생기지 않는다 + ④ 세류망 흐름 새김 → 최외곽부터 사슬 추적 → 도로 도달 여부(적/청) 판정 + ⑤ 최외곽 적색 셀 주변 확장 — 새로 추가한 셀에 적색이 없을 때까지 + ⑥ 도로 셀별 흐름 강도 + ⑦ 2차 전체 배수유역 외곽선 + ⑧ 기본 관 매설 위치 (도로 × 세류선 교차점) + +⑨ 이후(관 최소 개수 보충, 세부유역 분할)는 사용자가 관을 옮길 수 있어야 하므로 +B05에 남긴다. +""" + +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass, field +from typing import Any + +import numpy as np +from shapely.geometry import LineString + +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Descent import ContourDescent +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Expand import expand_by_red_boundary +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Flow import ( + FlowClassification, + RoadRaster, + largest_ring, + outer_boundary, + trace_flow, +) +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import ( + GridSpec, + TerrainGrid, + build_contour_cloud, + route_elevation_floor, +) +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Stream import ( + PrimaryRegion, + build_primary_region, +) +from common_util.common_util_route_geometry import ( + RouteVertex, + StructureCandidate, + find_stream_crossings, +) +from config.config_system import ( + DRAINAGE_GRID_SIZE_M, + DRAINAGE_INITIAL_RADIUS_M, + DRAINAGE_PIPE_MIN_SPACING_M, +) + +logger = logging.getLogger(__name__) + +# 강도 곡선 응답 간격(m). 도로 위 흐름 강도 표기는 이 간격으로 내보낸다. +_STRENGTH_OUTPUT_STEP_M = 5.0 + + +# ── ①~② 1차 배수유역 ──────────────────────────────────────────────────────── + + +def resolve_primary_region( + vertices: list[RouteVertex], + route_line: LineString, + contour_features: list[dict[str, Any]], + stream_features: list[dict[str, Any]], +) -> PrimaryRegion | None: + """도로 교차 세류선(상류측)과 노선을 반경 버퍼한 1차 배수유역과 격자 범위를 정한다. + + 상·하류 판정에 쓸 등고선은 노선 주변만 있으면 된다(교차점이 전부 노선 위이므로). + 도엽 전체를 읽으면 이 단계에서만 수십 초가 날아간다. + """ + floor = route_elevation_floor([vertex.z for vertex in vertices]) + near_bounds = route_line.buffer(DRAINAGE_INITIAL_RADIUS_M * 2.0).bounds + cloud = build_contour_cloud(contour_features, floor, near_bounds) + if cloud.is_empty: + logger.warning("배수유역: 노선 주변에 등고선이 없어 1차 영역을 정할 수 없습니다.") + return None + return build_primary_region( + route_line, stream_features, cloud, DRAINAGE_INITIAL_RADIUS_M, DRAINAGE_GRID_SIZE_M + ) + + +def preview_primary_region( + vertices: list[RouteVertex], + contour_features: list[dict[str, Any]], + stream_features: list[dict[str, Any]], +) -> PrimaryRegion | None: + """단계 검증용 — TIN·흐름 계산 없이 1차 배수유역 근거만 뽑는다.""" + if len(vertices) < 2: + return None + route_line = LineString([(vertex.x, vertex.y) for vertex in vertices]) + return resolve_primary_region(vertices, route_line, contour_features, stream_features) + + +@dataclass +class StagePreview: + """단계 검증 산출물 묶음. 기능을 붙일 때마다 여기에 항목이 하나씩 늘어난다. + + 확장을 거치면 격자와 해석 영역이 1차 영역보다 커진다. 화면·저장은 `region.spec`이 + 아니라 여기 `spec`/`domain`을 봐야 한다. + """ + + region: PrimaryRegion + spec: GridSpec | None = None + domain: np.ndarray | None = None + terrain: TerrainGrid | None = None + road: RoadRaster | None = None + flow: FlowClassification | None = None + descent: ContourDescent | None = None + expand_rounds: int = 0 + expand_closed: bool = False + expand_added_cells: int = 0 + # ⑥ 도로 위 흐름 강도 — (누가거리 m, 그 구간으로 모이는 상류 면적 ㎡). + strength_profile: list[tuple[float, float]] = field(default_factory=list) + # ⑦ 2차 전체 배수유역 외곽선(= 분수령). 적색 셀 전체를 폴리곤화한 것. + basin_boundary_xy: list[tuple[float, float]] = field(default_factory=list) + basin_area_m2: float = 0.0 + # 셀 → 도로 셀 귀속. B05가 세부유역을 나눌 때 이 배열이 있어야 한다. + routing: Any = None + # ⑧ 기본 관 매설 위치 — 도로 × 세류선 교차점. + pipes: list[StructureCandidate] = field(default_factory=list) + + +def preview_stages( + vertices: list[RouteVertex], + contour_features: list[dict[str, Any]], + stream_features: list[dict[str, Any]], +) -> StagePreview | None: + """지금까지 구현·검증된 단계를 순서대로 돌려 결과를 모은다. + + 현재 포함: ① 1차 배수유역 ② 격자 생성 ③ **등고선 하강 방향** ④ 도로 도달 판정 + ⑤ **최외곽 적색 셀 주변 확장**. + + ③은 보간면(TIN)을 쓰지 않는다. 높은 등고 라인에서 낮은 등고 라인으로 내려가며 방향을 + 세우므로 가짜 웅덩이·평탄면이 원리적으로 생기지 않는다(2026-07-31 사용자 지시로 방식 교체). + + ⑤는 최외곽에 적색이 남아 있으면 그 주변으로 넓혀 다시 분석하고, **새로 추가한 셀에 + 적색이 없으면** 멈춘다. + """ + if len(vertices) < 2: + return None + route_line = LineString([(vertex.x, vertex.y) for vertex in vertices]) + region = resolve_primary_region(vertices, route_line, contour_features, stream_features) + if region is None: + return None + + started = time.perf_counter() + floor = route_elevation_floor([vertex.z for vertex in vertices]) + expansion = expand_by_red_boundary( + region.spec, + region.cell_mask, + contour_features, + route_line, + region.split.upstream, + floor, + ) + if expansion is None: + logger.warning("배수유역: 등고선 하강 방향을 세우지 못해 흐름 판정을 건너뜁니다.") + return StagePreview(region=region) + + analysis = expansion.analysis + spec = analysis.spec + red = analysis.flow.reaches_road & analysis.flow.analyzed + + # ⑥ 흐름 강도 — 셀마다 물이 실제로 들어가는 도로 셀을 구해 도로 셀별로 센다. + # 색 판정은 세류 셀에서 멈추지만(거기서 도달이 확정되므로), 강도는 그 물이 세류를 타고 + # 최종적으로 어느 도로 셀로 들어가는지를 봐야 하므로 도로만 흡수점으로 두고 다시 따라간다. + routing = trace_flow(analysis.terrain, analysis.road) if analysis.road.count else None + strength_curve = _preview_strength(analysis, routing, red, route_line.length) + + # ⑦ 2차 전체 배수유역 외곽선 = 적색 셀 전체의 외곽. + boundary = outer_boundary(spec, red.reshape(spec.n_rows, spec.n_cols)) + basin_ring = largest_ring(boundary) if boundary is not None else [] + + # ⑧ 기본 관 매설 위치 = 도로 × 세류선 교차점(너무 가까운 것은 하나로 합침). + pipes = _base_pipes(vertices, stream_features) + + logger.info( + "배수유역: 단계 분석 %.1fs (확장 %d회, 최종 셀 %d개) — " + "2차 유역 %.0f㎡, 기본 관 %d개, 강도 곡선 %d점", + time.perf_counter() - started, + expansion.rounds, + spec.size, + int(red.sum()) * spec.cell_area_m2, + len(pipes), + int((strength_curve > 0).sum()), + ) + return StagePreview( + region=region, + spec=spec, + domain=analysis.domain, + terrain=analysis.terrain, + road=analysis.road, + flow=analysis.flow, + descent=analysis.descent, + expand_rounds=expansion.rounds, + expand_closed=expansion.closed, + expand_added_cells=expansion.added_cells, + strength_profile=_downsample_strength(strength_curve), + basin_boundary_xy=basin_ring, + basin_area_m2=int(red.sum()) * spec.cell_area_m2, + routing=routing, + pipes=pipes, + ) + + +def _preview_strength( + analysis: Any, routing: Any, red: np.ndarray, route_length_m: float +) -> np.ndarray: + """적색 셀이 실제로 들어가는 도로 셀을 세어 누가거리별 유입 면적 곡선을 만든다.""" + road = analysis.road + if routing is None or road.count == 0: + return np.zeros(1) + slots = routing.road_slot + counted = red & (slots >= 0) + strength = np.bincount(slots[counted], minlength=road.count).astype(np.float64) + return _strength_by_chainage( + road.chainage, strength * analysis.spec.cell_area_m2, route_length_m + ) + + +def _base_pipes( + vertices: list[RouteVertex], stream_features: list[dict[str, Any]] +) -> list[StructureCandidate]: + """도로 × 세류선 교차점을 기본 관 위치로 삼는다. 300m 보충 배치는 다음 단계다.""" + pipes: list[StructureCandidate] = [] + for candidate in find_stream_crossings(vertices, stream_features): + if pipes and candidate.chainage_m - pipes[-1].chainage_m < DRAINAGE_PIPE_MIN_SPACING_M: + continue + pipes.append(candidate) + return pipes + + +# ── 흐름 강도 곡선 ────────────────────────────────────────────────────────── + + +def _strength_by_chainage( + road_chainage: np.ndarray, strength_area: np.ndarray, total_length: float +) -> np.ndarray: + """도로 셀 강도를 1m 누가거리 구간으로 합산한 곡선(㎡/m 구간 합).""" + bins = max(1, int(np.ceil(total_length)) + 1) + if road_chainage.size == 0: + return np.zeros(bins) + index = np.clip(np.round(road_chainage).astype(np.int64), 0, bins - 1) + return np.bincount(index, weights=strength_area, minlength=bins) + + +def _downsample_strength(curve: np.ndarray) -> list[tuple[float, float]]: + """응답용으로 강도 곡선을 일정 간격으로 줄인다(구간 합 유지). + + 끝자락을 잘라내면 종점 부근 유입 면적이 통째로 사라지므로 0으로 채워 맞춘다. + """ + step = max(1, int(_STRENGTH_OUTPUT_STEP_M)) + if curve.size == 0: + return [] + padding = (-curve.size) % step + padded = np.append(curve, np.zeros(padding)) if padding else curve + summed = padded.reshape(-1, step).sum(axis=1) + return [ + (float(position * step), float(value)) for position, value in enumerate(summed) if value > 0 + ] diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Descent.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Descent.py similarity index 99% rename from B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Descent.py rename to B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Descent.py index faa429f4..98e87368 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Descent.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Descent.py @@ -32,7 +32,7 @@ from rasterio.features import rasterize from scipy.ndimage import distance_transform_edt from shapely.geometry import shape -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import ( +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import ( AZIMUTH_INVALID, AZIMUTH_SINK, AZIMUTH_STEPS, diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Expand.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Expand.py similarity index 97% rename from B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Expand.py rename to B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Expand.py index 54017008..c24d5982 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Expand.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Expand.py @@ -21,11 +21,11 @@ from typing import Any import numpy as np from shapely.geometry import LineString -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Descent import ( +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Descent import ( ContourDescent, build_contour_descent, ) -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Flow import ( +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Flow import ( FlowClassification, RoadRaster, burn_stream_flow, @@ -33,7 +33,7 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Flow import ( outermost_cells, rasterize_road, ) -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import GridSpec, TerrainGrid +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import GridSpec, TerrainGrid from config.config_system import ( DRAINAGE_RED_EXPAND_BAND_M, DRAINAGE_RED_EXPAND_MAX_ROUNDS, diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Export.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Export.py similarity index 95% rename from B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Export.py rename to B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Export.py index babe06a2..9e4c5e17 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Export.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Export.py @@ -5,7 +5,7 @@ `STAGES`에 이름을 하나 더 넣고 `write_stage()`를 호출하면 된다 — 파일명 규칙과 매니페스트 갱신은 여기서 일괄로 처리한다. -저장 위치: `storage/{회사}/{사용자}/{프로젝트}/B05_wf2_Route/drainage/` +저장 위치: `storage/{회사}/{사용자}/{프로젝트}/B04_wf1_Surface/drainage/` - `{단계번호}_{단계이름}.geojson` — WGS84 FeatureCollection, 피처마다 `kind` 속성 - `manifest.json` — 지금까지 남긴 단계 목록과 요약값 """ @@ -31,6 +31,8 @@ logger = logging.getLogger(__name__) STAGES: dict[str, str] = { "primary_region": "01", "flow_direction": "02", + # B05가 읽어 세부유역을 나누는 데 필요한 최소 배열·기하. 화살표·표고는 넣지 않는다. + "road_routing": "03", } _MANIFEST_FILENAME = "manifest.json" @@ -38,7 +40,9 @@ LonLat = Callable[[float, float], tuple[float, float]] def drainage_dir(stored_path: str) -> Path: - return Path(resolve_stored_project_path(stored_path)) / "B05_wf2_Route" / DRAINAGE_CACHE_DIRNAME + return ( + Path(resolve_stored_project_path(stored_path)) / "B04_wf1_Surface" / DRAINAGE_CACHE_DIRNAME + ) def write_stage( diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Flow.py similarity index 99% rename from B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py rename to B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Flow.py index ad6d141d..91ec8f0b 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Flow.py @@ -24,7 +24,7 @@ from scipy.spatial import cKDTree from shapely.geometry import LineString, MultiPolygon, Polygon, shape from shapely.ops import unary_union -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import ( +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import ( AZIMUTH_STEPS, ContourCloud, GridSpec, diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Grid.py similarity index 100% rename from B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py rename to B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Grid.py diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Stream.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Stream.py similarity index 98% rename from B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Stream.py rename to B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Stream.py index 0d337bd0..cd376ee8 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Stream.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Stream.py @@ -5,7 +5,7 @@ 전체**를 잡는다(2026-07-31 사용자 지시). 여기서 정해진 1차 배수유역의 bbox가 곧 격자 해석 범위가 된다. -표고 해석·격자 생성은 `B05_wf2_Route_Engine_Watershed_Grid.py`가 맡는다. +표고 해석·격자 생성은 `B04_wf1_Surface_Engine_Watershed_Grid.py`가 맡는다. """ from __future__ import annotations @@ -20,7 +20,7 @@ from scipy.spatial import cKDTree from shapely.geometry import LineString, MultiPolygon, Polygon, shape from shapely.ops import substring, unary_union -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import ( +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import ( ContourCloud, GridSpec, build_cell_mask, diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py b/B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py new file mode 100644 index 00000000..248a69f1 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py @@ -0,0 +1,482 @@ +"""배수유역 분석 API 라우터 (B04 — 관리자 확인용). + +계획 노선(B03 업로드 CSV)과 도엽 등고선·세류선으로 배수유역을 끝까지 분석하고, 결과를 +`storage/{프로젝트}/B04_wf1_Surface/drainage/`에 남긴다. 30초 안팎이 걸리므로 여기서 한 번만 +돌리고, 일반 사용자가 쓰는 B05는 저장분을 읽어 쓴다(2026-07-31 사용자 지시). + +좌표는 사업지 CRS(m)에서 계산하고 응답만 WGS84로 바꿔 내보낸다. +""" + +import asyncio +import base64 +import json +import logging +from pathlib import Path +from typing import Any +from uuid import UUID + +import numpy as np +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pyproj import Transformer +from shapely.geometry import Point, Polygon, box + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Analyze import preview_stages +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Export import write_grid_arrays, write_stage +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import ( + AZIMUTH_INVALID, + AZIMUTH_SINK, + AZIMUTH_STEPS, + mask_row_spans, +) +from B05_wf2_Route.B05_wf2_Route_Repository import get_surface_crs_epsg +from common_util.common_util_route_geometry import ( + StructureCandidate, + find_planned_route_file, + read_planned_route_csv, +) +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B04 Surface Watershed"]) + +# 도엽 레이어 파일명 (B04 전처리 산출물과 같은 위치) +_CONTOUR_FILE = "도엽_등고선.geojson" +_STREAM_FILE = "도엽_하천중심선.geojson" + + +def _sheet_dir(stored_path: str) -> Path: + return Path(resolve_stored_project_path(stored_path)) / "B04_wf1_Surface" / "processed" + + +def _route_input_dir(stored_path: str) -> Path: + """B03 업로드 폴더 — 계획 노선 파일이 여기 들어온다.""" + return Path(resolve_stored_project_path(stored_path)) / "B03_FileInput" / "input" + + +def _load_features(directory: Path, filename: str) -> list[dict[str, Any]]: + """도엽 GeoJSON을 읽어 피처 목록만 돌려준다. 없으면 빈 목록.""" + path = directory / filename + if not path.exists(): + return [] + try: + with path.open("r", encoding="utf-8") as file: + data = json.load(file) + except (OSError, json.JSONDecodeError): + logger.warning("도엽 GeoJSON을 읽지 못했습니다: %s", path) + return [] + features = data.get("features") + return features if isinstance(features, list) else [] + + +def _reproject_features( + features: list[dict[str, Any]], + transformer: Transformer | None, +) -> list[dict[str, Any]]: + """WGS84 도엽 좌표를 사업지 CRS(m)로 바꾼다. 거리·면적을 미터로 계산하기 위함.""" + if transformer is None: + return features + converted: list[dict[str, Any]] = [] + for feature in features: + geometry = feature.get("geometry") + if not geometry: + continue + coordinates = _map_coordinates(geometry.get("coordinates"), transformer) + if coordinates is None: + continue + converted.append( + { + "type": "Feature", + "properties": feature.get("properties") or {}, + "geometry": {"type": geometry.get("type"), "coordinates": coordinates}, + } + ) + return converted + + +def _map_coordinates(coordinates: Any, transformer: Transformer) -> Any: + """중첩 좌표 배열을 재귀적으로 변환한다.""" + if not isinstance(coordinates, list) or not coordinates: + return None + first = coordinates[0] + if isinstance(first, (int, float)): + x, y = transformer.transform(float(coordinates[0]), float(coordinates[1])) + return [x, y] + mapped = [_map_coordinates(item, transformer) for item in coordinates] + return [item for item in mapped if item is not None] + + +def _candidate_payload( + candidate: StructureCandidate, + to_lonlat: Any, +) -> dict[str, Any]: + lon, lat = to_lonlat(candidate.x, candidate.y) + return { + "chainage_m": round(candidate.chainage_m, 2), + "x": candidate.x, + "y": candidate.y, + "lon": lon, + "lat": lat, + "reason": candidate.reason, + "stream_name": candidate.stream_name, + } + + +async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse: + """계획 노선 파일과 도엽 피처, 좌표 변환기를 준비한다. + + 노선은 **B03에 업로드된 계획 노선 파일**에서 읽는다 — B05의 확정 경로가 아니다. + 배수유역 분석은 노선 설계보다 먼저 끝나 있어야 하기 때문이다(2026-07-31 사용자 지시). + """ + 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) + + 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) + if planned is None or len(planned.vertices) < 2: + return JSONResponse( + status_code=400, + content={ + "status": "error", + "message": f"계획 노선 파일을 읽지 못했습니다: {route_file.name}", + }, + ) + + # 노선 파일이 CRS를 명시하면 그 값을 따른다. 도엽 재투영도 같은 좌표계로 맞춘다. + source_crs = f"EPSG:{planned.epsg or 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) + + directory = _sheet_dir(stored_path) + streams = _reproject_features(_load_features(directory, _STREAM_FILE), to_metric_transformer) + contour_features = _reproject_features( + _load_features(directory, _CONTOUR_FILE), to_metric_transformer + ) + return { + "route_source": route_file.name, + "vertices": planned.vertices, + "route_line": planned.line, + "streams": streams, + "contours": contour_features, + "stored_path": stored_path, + "to_lonlat": lambda x, y: to_lonlat_transformer.transform(x, y), + } + + +@router.get("/{project_id}/drainage/primary-region", response_model=None) +async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse: + """1차 배수유역 근거를 돌려준다 — 단계 검증용, TIN·흐름 계산은 하지 않는다. + + 도로 교차점 상류로 이어진 세류망, 제외된 하류망, 그 상류망을 반경 버퍼한 1차 영역, + 그 bbox로 잡은 격자 정보를 함께 준다. 같은 내용을 영구저장소에 GeoJSON으로도 남겨 + QGIS 등으로 직접 열어 대조할 수 있게 한다. + """ + prepared = await _prepare(project_id) + if isinstance(prepared, JSONResponse): + return prepared + preview = await asyncio.to_thread( + preview_stages, + prepared["vertices"], + prepared["contours"], + prepared["streams"], + ) + if preview is None: + return JSONResponse( + status_code=400, + content={"status": "error", "message": "1차 배수유역을 정할 등고선이 없습니다."}, + ) + region = preview.region + to_lonlat = prepared["to_lonlat"] + # 확장을 거치면 격자·해석 영역이 1차 영역보다 커진다. 최종본을 써야 화면과 어긋나지 않는다. + spec = preview.spec or region.spec + domain = preview.domain if preview.domain is not None else region.cell_mask + payload = { + "status": "success", + "project_id": str(project_id), + "route_source": prepared["route_source"], + "radius_m": region.radius_m, + # 채택된 상류 세류망 = 1차 영역의 기준선. + "upstream_lines": [_line_lonlat(line, to_lonlat) for line in region.split.upstream], + # 도로 아래로 이어진 하류망 — 판정이 맞는지 눈으로 대조하기 위해 함께 준다. + "downstream_lines": [_line_lonlat(line, to_lonlat) for line in region.split.downstream], + "no_contact_count": region.split.no_contact, + # 노선이 1차 영역 밖으로 나간 길이(m). 크면 반경을 올려야 한다는 신호. + "road_outside_m": round(region.road_outside_m, 1), + # 1차 영역(버퍼 합집합) 외곽 링 목록. + "region_rings": _polygon_rings(region.area, to_lonlat), + "grid": { + "cell_m": spec.cell_m, + "rows": spec.n_rows, + "cols": spec.n_cols, + # bbox 전체 셀 수와, 해석 영역에 실제로 생성된 셀 수(확장 반영). + "bbox_cells": spec.size, + "cells": int(domain.sum()) if domain is not None else 0, + "width_m": round(spec.n_cols * spec.cell_m, 1), + "height_m": round(spec.n_rows * spec.cell_m, 1), + # 격자 bbox 링. 프론트는 이 사각형을 rows×cols로 나눠 행·열 좌표를 얻는다. + "bbox_lonlat": _grid_bbox_lonlat(spec, to_lonlat), + # 실제 생성된 셀을 행별 연속 구간 [행, 시작열, 끝열]으로 압축해 보낸다. + # 셀을 낱개로 보내면 수십만 건이라 응답이 감당되지 않는다. + "row_spans": [list(span) for span in mask_row_spans(domain)] + if domain is not None + else [], + }, + # 최외곽 적색 셀 주변 확장 결과. + "expansion": { + "rounds": preview.expand_rounds, + "closed": preview.expand_closed, + "added_cells": preview.expand_added_cells, + "initial_cells": region.active_cells, + }, + # 셀별 흐름 방향과 도로 도달 여부. row_spans 순서(행 → 구간 → 열 오름차순)로 1바이트씩. + "flow": _flow_payload(preview, domain), + # ⑦ 2차 전체 배수유역 외곽선(= 분수령)과 면적. + "basin_polygon_lonlat": [list(to_lonlat(x, y)) for x, y in preview.basin_boundary_xy], + "basin_area_m2": round(preview.basin_area_m2, 1), + # ⑥ 도로 위 흐름 강도 — [누가거리 m, 그 구간으로 모이는 상류 면적 ㎡]. + "strength_profile": [ + [round(chainage, 1), round(area, 1)] for chainage, area in preview.strength_profile + ], + # ⑧ 기본 관 매설 위치 — 도로 × 세류선 교차점. + "pipes": [_candidate_payload(pipe, to_lonlat) for pipe in preview.pipes], + } + # 단계 산출물을 영구저장소에 남긴다 — 기능을 붙일 때마다 여기에 단계가 하나씩 늘어난다. + payload["saved_to"] = write_stage( + prepared["stored_path"], + "primary_region", + { + "primary_region": _as_polygons(region.area), + "upstream": region.split.upstream, + "downstream": region.split.downstream, + "route": [prepared["route_line"]], + "grid_bbox": [_grid_bbox_polygon(spec)], + # ⑦ 2차 전체 배수유역 외곽선, ⑧ 기본 관 위치(누가거리·근거 포함). + "basin_boundary": _boundary_geometry(preview.basin_boundary_xy), + "pipe": [ + ( + Point(pipe.x, pipe.y), + { + "chainage_m": round(pipe.chainage_m, 2), + "reason": pipe.reason, + "stream_name": pipe.stream_name, + }, + ) + for pipe in preview.pipes + ], + }, + { + "radius_m": region.radius_m, + "road_outside_m": payload["road_outside_m"], + "no_contact_count": region.split.no_contact, + "basin_area_m2": payload["basin_area_m2"], + "pipe_count": len(preview.pipes), + # 기하(bbox·셀 구간)는 파일 본문에 있으므로 요약 수치만 남긴다. + "grid": { + key: value + for key, value in payload["grid"].items() + if key not in {"bbox_lonlat", "row_spans"} + }, + }, + to_lonlat, + ) + _write_stage_arrays(prepared["stored_path"], preview, domain, spec) + _write_road_routing(prepared["stored_path"], preview, spec, prepared["route_line"], to_lonlat) + return payload + + +def _write_road_routing( + stored_path: str, preview: Any, spec: Any, route_line: Any, to_lonlat: Any +) -> None: + """B05가 세부유역을 나눌 때 쓸 최소 산출물을 남긴다. + + B05는 일반 사용자용이라 가벼워야 한다. 화살표(방향 코드)·밴드 표고 같은 확인용 배열은 + 빼고, **셀 → 도로 셀 귀속**과 도로 셀 제원만 담는다. 여기에 표고를 함께 넣는 이유는 + 유역 낙차를 내려면 셀 표고가 필요해서다(2026-07-31 사용자 지시). + """ + routing = preview.routing + road = preview.road + if routing is None or road is None or road.count == 0: + return + write_grid_arrays( + stored_path, + "road_routing", + spec, + { + "road_slot": routing.road_slot, + "path_length": routing.path_length, + "strength": routing.strength, + "road_cell_index": road.cell_index, + "road_chainage": road.chainage, + "elevation": preview.terrain.elevation.reshape(-1), + }, + { + "road_cells": road.count, + "reached_cells": int((routing.road_slot >= 0).sum()), + "basin_area_m2": round(preview.basin_area_m2, 1), + "pipe_count": len(preview.pipes), + }, + ) + # B05가 그대로 그릴 기하 — 계획도로선 · 기본 배관 · 2차 전체 배수유역, 이 셋뿐이다. + write_stage( + stored_path, + "road_routing", + { + "route": [route_line], + "basin_boundary": _boundary_geometry(preview.basin_boundary_xy), + "pipe": [ + ( + Point(pipe.x, pipe.y), + {"chainage_m": round(pipe.chainage_m, 2), "reason": pipe.reason}, + ) + for pipe in preview.pipes + ], + }, + { + "basin_area_m2": round(preview.basin_area_m2, 1), + "pipe_count": len(preview.pipes), + "route_length_m": round(route_line.length, 1), + }, + to_lonlat, + ) + + +def _write_stage_arrays(stored_path: str, preview: Any, domain: Any, spec: Any) -> None: + """격자 규모 배열(셀 마스크·흐름 방향·도달 여부)을 단계별 `.npz`로 남긴다.""" + if domain is not None: + write_grid_arrays( + stored_path, + "primary_region", + spec, + {"mask": domain}, + { + "cells": int(domain.sum()), + "bbox_cells": spec.size, + "expand_rounds": preview.expand_rounds, + "expand_closed": preview.expand_closed, + }, + ) + flow = preview.flow + if flow is None: + return + arrays = { + "direction": flow.direction.reshape(spec.n_rows, spec.n_cols), + "reaches_road": flow.reaches_road.reshape(spec.n_rows, spec.n_cols), + "analyzed": flow.analyzed.reshape(spec.n_rows, spec.n_cols), + # 사후 진단용 — 수신 셀이 있어야 사슬을 다시 따라가 볼 수 있다. + "receiver": preview.terrain.receiver.reshape(spec.n_rows, spec.n_cols), + } + if flow.burned is not None: + arrays["burned"] = flow.burned.reshape(spec.n_rows, spec.n_cols) + if preview.descent is not None: + arrays["band_elevation"] = preview.descent.band_elevation + # ⑥ 흐름 강도 곡선 — 기하가 아니라 수치 곡선이라 GeoJSON이 아닌 여기에 함께 담는다. + if preview.strength_profile: + curve = np.asarray(preview.strength_profile, dtype=np.float64) + arrays["strength_chainage_m"] = curve[:, 0] + arrays["strength_area_m2"] = curve[:, 1] + write_grid_arrays( + stored_path, + "flow_direction", + spec, + arrays, + { + "azimuth_steps": AZIMUTH_STEPS, + "sink_code": AZIMUTH_SINK, + "invalid_code": AZIMUTH_INVALID, + "analyzed": int(flow.analyzed.sum()), + "reaches_road": int((flow.reaches_road & flow.analyzed).sum()), + "no_road": int((~flow.reaches_road & flow.analyzed).sum()), + "burned": 0 if flow.burned is None else int(flow.burned.sum()), + "outer_seeds": flow.outer_seeds, + "interior_seeds": flow.interior_seeds, + "strength_points": len(preview.strength_profile), + "strength_total_m2": round(sum(area for _, area in preview.strength_profile), 1), + }, + ) + + +def _flow_payload(preview: Any, domain: Any) -> dict[str, Any] | None: + """셀별 흐름 방향·도로 도달 여부를 바이트 배열로 압축한다. + + 셀이 수십만 개라 JSON 객체로는 못 보낸다. 셀 하나당 1바이트로 줄이고 base64로 싣는다: + 하위 6비트(0x3F) = 32방위 코드(0~31, 0=화면 오른쪽·시계방향), 32=제자리, 33=표고 없음 + 최상위 비트(0x80) = 도로 도달(적색). 꺼져 있으면 미도달(백색 화살표). + 바이트 순서는 `grid.row_spans`를 행 → 구간 → 열 오름차순으로 훑은 순서와 같다. + """ + flow = preview.flow + if flow is None or domain is None: + return None + order = np.flatnonzero(domain.reshape(-1)) + analyzed = flow.analyzed[order] + reaches = flow.reaches_road[order] + packed = np.clip(flow.direction[order], 0, AZIMUTH_INVALID).astype(np.uint8) + packed |= np.where(reaches, 0x80, 0).astype(np.uint8) + burned = flow.burned + return { + "encoding": "base64-uint8", + "azimuth_steps": AZIMUTH_STEPS, + "sink_code": AZIMUTH_SINK, + "invalid_code": AZIMUTH_INVALID, + "cells": int(order.size), + "reaches_road": int((reaches & analyzed).sum()), + "no_road": int((~reaches & analyzed).sum()), + # 격자에는 있으나 등고선 TIN 밖이라 표고가 없어 판정 못한 셀. + "unanalyzed": int((~analyzed).sum()), + # 확정된 상류 세류망을 따라 흐름을 강제로 새긴 셀 수. + "burned": 0 if burned is None else int(burned[order].sum()), + "outer_seeds": flow.outer_seeds, + "interior_seeds": flow.interior_seeds, + "data": base64.b64encode(packed.tobytes()).decode("ascii"), + } + + +def _boundary_geometry(ring: list[tuple[float, float]]) -> list[Any]: + """2차 유역 외곽 링을 저장용 폴리곤으로 만든다(정점 3개 미만이면 비운다).""" + return [Polygon(ring)] if len(ring) >= 4 else [] + + +def _boundary_geometry(ring: list[tuple[float, float]]) -> list[Any]: + """2차 유역 외곽 링을 저장용 폴리곤으로 만든다(정점 3개 미만이면 비운다).""" + return [Polygon(ring)] if len(ring) >= 4 else [] + + +def _as_polygons(geometry: Any) -> list[Any]: + if geometry is None or geometry.is_empty: + return [] + return list(geometry.geoms) if geometry.geom_type == "MultiPolygon" else [geometry] + + +def _grid_bbox_polygon(spec: Any) -> Polygon: + x_max = spec.x_min + spec.n_cols * spec.cell_m + y_min = spec.y_max - spec.n_rows * spec.cell_m + return box(spec.x_min, y_min, x_max, spec.y_max) + + +def _line_lonlat(line: Any, to_lonlat: Any) -> list[list[float]]: + return [list(to_lonlat(x, y)) for x, y in line.coords] + + +def _polygon_rings(geometry: Any, to_lonlat: Any) -> list[list[list[float]]]: + """폴리곤/멀티폴리곤의 외곽 링만 뽑아 lonlat으로 바꾼다.""" + if geometry is None or geometry.is_empty: + return [] + parts = geometry.geoms if geometry.geom_type == "MultiPolygon" else [geometry] + return [[list(to_lonlat(x, y)) for x, y in part.exterior.coords] for part in parts] + + +def _grid_bbox_lonlat(spec: Any, to_lonlat: Any) -> list[list[float]]: + x_min = spec.x_min + x_max = spec.x_min + spec.n_cols * spec.cell_m + y_max = spec.y_max + y_min = spec.y_max - spec.n_rows * spec.cell_m + corners = ((x_min, y_min), (x_min, y_max), (x_max, y_max), (x_max, y_min), (x_min, y_min)) + return [list(to_lonlat(x, y)) for x, y in corners] diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index 42f3a300..b65f302a 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -7,6 +7,7 @@ import { type VWorldMeta, } from "./B04_wf1_Surface_Api_Fetch"; import { niceScaleDistance } from "./B04_wf1_Surface_UI_Camera"; +import { createWatershedOverlay } from "./B04_wf1_Surface_UI_Watershed"; import { computeMapRect, createNormalizer, @@ -15,6 +16,7 @@ import { prepareLayer, type GeoJsonCollection, type MapRect, + type Normalizer, type PreparedLayer, type ViewState, } from "./B04_wf1_Surface_UI_MapRender"; @@ -130,6 +132,8 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { let currentProjectId: string | null = null; let referenceBounds: SurfaceBounds | null = null; let meta: VWorldMeta | null = null; + // 배수유역 오버레이가 lon/lat을 화면 좌표로 옮길 때 쓴다. 레이어 로드 시 1회 만든다. + let normalizer: Normalizer | null = null; // 사전 투영된 렌더용 레이어. 원본 GeoJSON은 변형하지 않으며 투영 후에는 참조를 잡아두지 않는다. const preparedLayers = new Map(); const activeBackgrounds = new Set(BACKGROUND_LAYERS); @@ -217,6 +221,14 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { }); gisButtons.append(contourLabelButton); + // 배수유역 분석 오버레이 — 계산은 백엔드가 하고 여기서는 겹쳐 그리기만 한다. + const watershed = createWatershedOverlay(() => { + const text = watershed.status(); + if (text) status.textContent = text; + scheduleDraw(); + }); + gisButtons.append(watershed.button); + function updateImageTransform(): void { backgroundImages.forEach((image) => { image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; @@ -354,12 +366,12 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { if (sequence !== loadSequence) return; meta = nextMeta; // 좌표 변환은 여기서 1회만 수행하고, 이후 프레임은 사전 투영 결과만 사용한다. - const normalizer = createNormalizer(nextMeta); + normalizer = createNormalizer(nextMeta); let featureCount = 0; loadedLayers.forEach(([layer, data]) => { if (!data) return; featureCount += data.features?.length ?? 0; - preparedLayers.set(layer, prepareLayer(data, normalizer, CONTOUR_LABEL_KEYS[layer])); + preparedLayers.set(layer, prepareLayer(data, normalizer!, CONTOUR_LABEL_KEYS[layer])); }); BACKGROUND_LAYERS.forEach((layer) => { backgroundImages.get(layer)!.src = `${getVWorldMapUrl(projectId, layer)}&_t=${Date.now()}`; @@ -422,6 +434,8 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { root, render(projectId, nextReferenceBounds) { currentProjectId = projectId; + watershed.reset(); + watershed.setProject(projectId); referenceBounds = nextReferenceBounds ?? null; void loadLayers(); }, diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts new file mode 100644 index 00000000..0f55463e --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts @@ -0,0 +1,397 @@ +import { fetchWatershedAnalysis, type WatershedAnalysis } from "./B04_wf1_Surface_Api_Fetch"; +import type { Normalizer, ViewState } from "./B04_wf1_Surface_UI_MapRender"; + +/* ============================================================================= + * 배수유역 분석 오버레이 (B04 — 관리자 확인용) + * + * 2D 배경지도 위에 배수유역 분석 결과를 겹쳐 그린다. 계산은 백엔드가 하고 여기서는 + * 그리기만 한다. 30초 안팎이 걸리는 요청이라 버튼을 눌렀을 때만 돈다. + * + * 겹쳐 그리는 것 + * · 해석 격자 — 1차 영역에 걸치는 셀만, 흰 선 + * · 셀별 흐름 — 도로 도달 적색 / 미도달 파랑 채움 + 백색 화살표 / 표고없음 회색 + * · 상류 세류망(굵은 파랑) · 하류망(회색 파선) · 1차 영역(초록 채움) + * · 2차 전체 배수유역 외곽선(갈색 파선) · 기본 관 위치 + * ========================================================================== */ + +// 해석 격자 셀 선 — 등고선·세류 위에 얹으므로 흰색으로 둔다. +const GRID_LINE_COLOR = "rgba(255, 255, 255, 0.55)"; +// 흐름 판정 색 — 도로로 물이 오는 셀은 적색, 오지 않는 셀은 파랑 채움 + 백색 화살표. +const FLOW_TO_ROAD_FILL = "rgba(220, 38, 38, 0.28)"; +const FLOW_TO_ROAD_LINE = "rgba(153, 27, 27, 0.95)"; +const FLOW_AWAY_FILL = "rgba(37, 99, 235, 0.22)"; +const FLOW_AWAY_LINE = "rgba(255, 255, 255, 0.95)"; +/** 등고선 TIN 밖이라 표고가 없어 판정하지 못한 셀 — 미도달(파랑)과 구분한다. */ +const FLOW_UNKNOWN_FILL = "rgba(120, 113, 108, 0.18)"; +/** 셀이 이보다 작으면 화살표가 뭉개져 읽히지 않으므로 채움색만 남긴다(px). */ +const ARROW_MIN_PX = 7; +/** 2차 전체 배수유역 외곽선 = 분수령. */ +const BASIN_RING_COLOR = "rgba(146, 64, 14, 0.95)"; +/** 기본 관 마커. */ +const PIPE_COLOR = "rgba(249, 115, 22, 0.95)"; + +export interface WatershedOverlay { + /** 레이어 토글 버튼. 지도 헤더의 GIS 버튼 줄에 넣는다. */ + button: HTMLButtonElement; + /** 켜져 있는지. draw() 호출 전에 확인한다. */ + visible: () => boolean; + /** 상태 문구(분석 요약 또는 오류). 없으면 빈 문자열. */ + status: () => string; + /** 프로젝트가 바뀌면 받아 둔 분석 결과를 버린다. */ + reset: () => void; + /** 현재 프로젝트를 알려 준다. 지정 전에는 버튼이 아무 일도 하지 않는다. */ + setProject: (projectId: string) => void; + draw: (context: CanvasRenderingContext2D, map: Normalizer, view: ViewState) => void; +} + +export function createWatershedOverlay(onChange: () => void): WatershedOverlay { + let analysis: WatershedAnalysis | null = null; + let shown = false; + let statusText = ""; + let flowCache: { source: string; bytes: Uint8Array } | null = null; + + const button = document.createElement("button"); + button.type = "button"; + button.className = "b04-map__layer-button b04-map__layer-button--gis"; + button.textContent = "배수유역"; + button.style.setProperty("--b04-layer-color", "#dc2626"); + button.setAttribute("aria-pressed", "false"); + button.title = + "계획 노선(B03 업로드)과 도엽 등고선·세류선으로 배수유역을 분석합니다. " + + "30초 안팎이 걸리며 결과는 영구저장소에 남습니다."; + + let projectId: string | null = null; + + function strokeLonLat( + context: CanvasRenderingContext2D, + line: ReadonlyArray, + map: Normalizer, + view: ViewState, + ): void { + if (line.length < 2) return; + const ax = view.mapRect.width * view.scale; + const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; + const ay = view.mapRect.height * view.scale; + const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; + context.beginPath(); + line.forEach(([lon, lat], index) => { + const x = ((lon - map.lonMin) / map.lonRange) * ax + bx; + const y = (1 - (lat - map.latMin) / map.latRange) * ay + by; + if (index === 0) context.moveTo(x, y); + else context.lineTo(x, y); + }); + context.stroke(); + } + + /** 흐름 방향 바이트를 셀 순서대로 디코드한다(캐시 — 매 프레임 다시 풀지 않는다). */ + function flowBytes(region: WatershedAnalysis): Uint8Array | null { + if (!region.flow) return null; + if (flowCache?.source === region.flow.data) return flowCache.bytes; + const binary = atob(region.flow.data); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + flowCache = { source: region.flow.data, bytes }; + return bytes; + } + + /** 1차 영역에 걸쳐 실제로 생성된 셀만 그린다. + * + * bbox 전체를 채우지 않는다 — 백엔드가 준 행별 구간(row_spans)만 그린다. 흐름 판정이 + * 있으면 셀마다 방향 화살표를 얹고, 도로에 물이 닿는 셀은 적색·닿지 않으면 파랑으로 + * 칠한다. 셀이 화면에서 작아지면 화살표가 안 보이므로 채움색만 남긴다. */ + function drawGridCells( + context: CanvasRenderingContext2D, + map: Normalizer, + view: ViewState, + region: WatershedAnalysis, + ): void { + const ring = region.grid.bbox_lonlat; + if (ring.length < 4) return; + const lons = ring.map(([lon]) => lon); + const lats = ring.map(([, lat]) => lat); + const ax = view.mapRect.width * view.scale; + const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; + const ay = view.mapRect.height * view.scale; + const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; + const left = ((Math.min(...lons) - map.lonMin) / map.lonRange) * ax + bx; + const right = ((Math.max(...lons) - map.lonMin) / map.lonRange) * ax + bx; + const top = (1 - (Math.max(...lats) - map.latMin) / map.latRange) * ay + by; + const bottom = (1 - (Math.min(...lats) - map.latMin) / map.latRange) * ay + by; + + const { rows, cols, row_spans: spans } = region.grid; + const cellW = (right - left) / Math.max(cols, 1); + const cellH = (bottom - top) / Math.max(rows, 1); + const cellPx = Math.min(Math.abs(cellW), Math.abs(cellH)); + const bytes = flowBytes(region); + + context.save(); + context.setLineDash([]); + context.lineCap = "round"; + let cursor = 0; // row_spans를 훑은 순서 = 흐름 바이트 순서 + spans.forEach(([row, colStart, colEnd]) => { + const count = colEnd - colStart + 1; + const base = cursor; + cursor += count; + const y = top + cellH * row; + if (y + cellH < -40 || y > view.height + 40) return; + const x = left + cellW * colStart; + const width = cellW * count; + if (x + width < -40 || x > view.width + 40) return; + + if (!bytes) { + // 흐름 판정 전 — 격자만 흰 선으로 보여 준다. + if (cellPx >= 2) { + context.strokeStyle = GRID_LINE_COLOR; + context.lineWidth = 0.5; + context.beginPath(); + for (let col = colStart; col <= colEnd; col += 1) { + context.rect(left + cellW * col, y, cellW, cellH); + } + context.stroke(); + } else { + context.fillStyle = "rgba(255, 255, 255, 0.2)"; + context.fillRect(x, y, width, cellH); + } + return; + } + const sink = region.flow?.sink_code ?? 32; + const invalid = region.flow?.invalid_code ?? 33; + const steps = region.flow?.azimuth_steps ?? 32; + for (let offset = 0; offset < count; offset += 1) { + drawFlowCell( + context, + bytes[base + offset], + left + cellW * (colStart + offset), + y, + cellW, + cellH, + cellPx, + { sink, invalid, steps }, + ); + } + }); + context.restore(); + } + + /** 셀 하나 — 도달 여부로 칠하고, 충분히 크면 32방위 흐름 화살표를 얹는다. */ + function drawFlowCell( + context: CanvasRenderingContext2D, + code: number, + x: number, + y: number, + cellW: number, + cellH: number, + cellPx: number, + codes: { sink: number; invalid: number; steps: number }, + ): void { + const azimuth = code & 0x3f; + const reaches = (code & 0x80) !== 0; + // 표고가 없어 판정 못한 셀 — 미도달(파랑 채움)과 구분해야 오독이 없다. + const unanalyzed = azimuth === codes.invalid; + context.fillStyle = unanalyzed + ? FLOW_UNKNOWN_FILL + : reaches + ? FLOW_TO_ROAD_FILL + : FLOW_AWAY_FILL; + context.fillRect(x, y, cellW, cellH); + if (cellPx >= 2) { + context.strokeStyle = GRID_LINE_COLOR; + context.lineWidth = 0.5; + context.strokeRect(x, y, cellW, cellH); + } + if (cellPx < ARROW_MIN_PX || unanalyzed) return; + const stroke = reaches ? FLOW_TO_ROAD_LINE : FLOW_AWAY_LINE; + const midX = x + cellW / 2; + const midY = y + cellH / 2; + if (azimuth === codes.sink) { + // 제자리(싱크) — 방향이 없으므로 점으로 표시한다. + context.fillStyle = stroke; + context.beginPath(); + context.arc(midX, midY, Math.max(1, cellPx * 0.12), 0, Math.PI * 2); + context.fill(); + return; + } + // 코드 0 = 화면 오른쪽(+x), 시계방향(캔버스 y는 아래가 +). + const angle = (azimuth * 2 * Math.PI) / codes.steps; + const unitX = Math.cos(angle); + const unitY = Math.sin(angle); + const reach = cellPx * 0.38; + const tipX = midX + unitX * reach; + const tipY = midY + unitY * reach; + context.strokeStyle = stroke; + context.lineWidth = Math.max(0.6, cellPx * 0.09); + context.beginPath(); + context.moveTo(midX - unitX * reach, midY - unitY * reach); + context.lineTo(tipX, tipY); + context.stroke(); + // 촉 — 진행 방향 기준 좌우로 짧게 접는다. + const head = cellPx * 0.18; + context.beginPath(); + context.moveTo(tipX, tipY); + context.lineTo(tipX - (unitX + unitY * 0.7) * head, tipY - (unitY - unitX * 0.7) * head); + context.moveTo(tipX, tipY); + context.lineTo(tipX - (unitX - unitY * 0.7) * head, tipY - (unitY + unitX * 0.7) * head); + context.stroke(); + } + + /** 1차 배수유역 근거를 겹쳐 그린다 — 단계 검증용. */ + function drawPrimaryRegion( + context: CanvasRenderingContext2D, + map: Normalizer, + view: ViewState, + region: WatershedAnalysis, + ): void { + context.save(); + // ① 해석 격자 — bbox 테두리 + 실제 셀 눈금. + drawGridCells(context, map, view, region); + // ② 1차 배수유역 = 상류 세류망의 반경 버퍼 합집합. + context.setLineDash([]); + context.lineWidth = 2; + context.strokeStyle = "rgba(5, 150, 105, 0.95)"; + context.fillStyle = "rgba(16, 185, 129, 0.12)"; + region.region_rings.forEach((ring) => { + strokeLonLat(context, ring, map, view); + context.fill(); + }); + // ③ 도로 아래로 이어진 하류망 — 판정이 맞는지 대조하도록 회색 파선으로 남긴다. + context.setLineDash([6, 5]); + context.lineWidth = 2; + context.strokeStyle = "rgba(120, 113, 108, 0.85)"; + region.downstream_lines.forEach((line) => strokeLonLat(context, line, map, view)); + // ④ 채택된 상류망 = 1차 영역의 기준선. 가장 굵게, 맨 위에. + context.setLineDash([]); + context.lineWidth = 4; + context.strokeStyle = "rgba(29, 78, 216, 0.95)"; + region.upstream_lines.forEach((line) => strokeLonLat(context, line, map, view)); + context.restore(); + } + + /** ⑦ 2차 전체 배수유역 외곽선(= 분수령)과 ⑧ 기본 관 위치. */ + function drawBasinAndPipes( + context: CanvasRenderingContext2D, + map: Normalizer, + view: ViewState, + region: WatershedAnalysis, + ): void { + context.save(); + if (region.basin_polygon_lonlat.length > 2) { + context.setLineDash([8, 5]); + context.lineWidth = 2.5; + context.strokeStyle = BASIN_RING_COLOR; + strokeLonLat(context, region.basin_polygon_lonlat, map, view); + } + context.setLineDash([]); + const ax = view.mapRect.width * view.scale; + const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; + const ay = view.mapRect.height * view.scale; + const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; + region.pipes.forEach((pipe, index) => { + const x = ((pipe.lon - map.lonMin) / map.lonRange) * ax + bx; + const y = (1 - (pipe.lat - map.latMin) / map.latRange) * ay + by; + context.beginPath(); + context.arc(x, y, 7, 0, Math.PI * 2); + context.fillStyle = PIPE_COLOR; + context.fill(); + context.lineWidth = 1.5; + context.strokeStyle = "#111827"; + context.stroke(); + context.fillStyle = "#111827"; + context.font = "bold 10px sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText(String(index + 1), x, y); + }); + context.restore(); + } + + function regionSummary(region: WatershedAnalysis): string { + const cells = region.grid.cells.toLocaleString(); + const outside = + region.road_outside_m > 0 ? ` · 노선 이탈 ${Math.round(region.road_outside_m)}m` : ""; + const unknown = + region.flow && region.flow.unanalyzed > 0 + ? ` / 표고없음 ${region.flow.unanalyzed.toLocaleString()}(회)` + : ""; + const burned = + region.flow && region.flow.burned > 0 + ? ` · 세류망 새김 ${region.flow.burned.toLocaleString()}셀` + : ""; + const flow = region.flow + ? ` · 흐름 도로도달 ${region.flow.reaches_road.toLocaleString()}(적) / ` + + `미도달 ${region.flow.no_road.toLocaleString()}(청)${unknown}, ` + + `최외곽 출발 ${region.flow.outer_seeds.toLocaleString()} + ` + + `내부 보충 ${region.flow.interior_seeds.toLocaleString()}${burned}` + : " · 흐름 판정 없음"; + const expansion = region.expansion + ? ` · 확장 ${region.expansion.rounds}회` + + `(${region.expansion.initial_cells.toLocaleString()}→${cells}셀, ` + + `${region.expansion.closed ? "닫힘" : "상한 도달"})` + : ""; + const basin = region.basin_area_m2 + ? ` · 2차 유역 ${formatArea(region.basin_area_m2)}, 기본 관 ${region.pipes.length}개` + : ""; + return ( + `1차 영역(반경 ${region.radius_m}m): 상류망 ${region.upstream_lines.length}조각 채택 / ` + + `하류망 ${region.downstream_lines.length}조각·미연결 ${region.no_contact_count}개 제외 · ` + + `격자 ${region.grid.cell_m}m(도로 시점 기준) 셀 ${cells}개${outside}${expansion}${basin}${flow}` + ); + } + + function formatArea(areaM2: number): string { + return areaM2 >= 10000 ? `${(areaM2 / 10000).toFixed(2)}ha` : `${Math.round(areaM2)}㎡`; + } + + /** 켤 때마다 다시 요청한다 — config를 바꾸고 재시작했는데 캐시된 옛 결과가 나오면 + * 검증이 성립하지 않는다. 끌 때만 요청 없이 숨긴다. */ + async function toggle(): Promise { + if (!projectId) return; + if (shown) { + shown = false; + button.classList.remove("is-active"); + button.setAttribute("aria-pressed", "false"); + statusText = ""; + onChange(); + return; + } + button.disabled = true; + statusText = "배수유역을 분석하는 중… (30초 안팎)"; + onChange(); + try { + analysis = await fetchWatershedAnalysis(projectId); + shown = true; + button.classList.add("is-active"); + button.setAttribute("aria-pressed", "true"); + statusText = regionSummary(analysis); + } catch (error) { + statusText = error instanceof Error ? error.message : "배수유역 분석에 실패했습니다."; + } finally { + button.disabled = false; + onChange(); + } + } + + button.addEventListener("click", () => void toggle()); + + return { + button, + visible: () => shown && analysis !== null, + status: () => statusText, + reset() { + analysis = null; + flowCache = null; + shown = false; + statusText = ""; + button.classList.remove("is-active"); + button.setAttribute("aria-pressed", "false"); + }, + setProject(next: string) { + projectId = next; + }, + draw(context, map, view) { + if (!shown || !analysis) return; + drawGridCells(context, map, view, analysis); + drawPrimaryRegion(context, map, view, analysis); + drawBasinAndPipes(context, map, view, analysis); + }, + }; +} diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index c22140e9..3d934025 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -300,107 +300,15 @@ export interface DrainageBasinResponse { route_id: number; /** 산정에 실제 사용된 배관 지점 — 유역이 없는 관도 포함(마커 동기화용). */ pipes: DrainageCandidate[]; - /** 2차 전체 배수유역 외곽선 = 분수령. 세부유역은 전부 이 안쪽이라 능선을 따로 그리지 않는다. */ + /** B04가 분석에 쓴 계획 노선 선형(lon/lat). */ + route_lonlat: Array<[number, number]>; + /** 2차 전체 배수유역 외곽선 = 분수령. B04 산출물을 그대로 받는다. */ main_polygon_lonlat: Array<[number, number]>; - /** 도로 위 흐름 강도 — [누가거리 m, 그 구간으로 모이는 상류 면적 ㎡]. 관 추가 판단 근거. */ - strength_profile: Array<[number, number]>; - /** 해석에 실제 사용된 격자 한 변(m). 셀 수 상한에 걸리면 백엔드가 키워서 돌려준다. */ + /** B04 해석 격자 한 변(m). */ grid_cell_m: number; basins: DrainageBasin[]; } -/** 1차 배수유역 근거(단계 검증용). TIN·흐름 계산 없이 세류 상·하류 판정과 격자 범위만 준다. */ -export interface DrainagePrimaryRegion { - status: string; - project_id: string; - route_id: number; - radius_m: number; - /** 도로와 만난 세류선의 상류측 = 1차 영역의 기준선. */ - upstream_lines: Array>; - /** 교차했으나 하류로 판정해 제외한 조각. 판정이 맞는지 눈으로 대조하는 용도. */ - downstream_lines: Array>; - /** 상·하류 어느 망에도 이어지지 않아 제외한 세류 조각 수. */ - no_contact_count: number; - /** 노선이 1차 영역 밖으로 나간 길이(m). 크면 반경을 올려야 한다는 신호. */ - road_outside_m: number; - /** 1차 영역(상류 세류망 버퍼 합집합)의 외곽 링 목록. */ - region_rings: Array>; - grid: { - cell_m: number; - rows: number; - cols: number; - /** bbox 전체 셀 수(참고값). */ - bbox_cells: number; - /** 1차 영역에 걸쳐 실제로 생성된 셀 수. */ - cells: number; - width_m: number; - height_m: number; - /** 격자 bbox 링. 화면은 이 사각형을 rows×cols로 나눠 셀 좌표를 얻는다. */ - bbox_lonlat: Array<[number, number]>; - /** 실제 생성된 셀 구간 [행, 시작열, 끝열(포함)]. 낱개 셀 대신 구간으로 온다. */ - row_spans: Array<[number, number, number]>; - }; - /** 최외곽 적색 셀 주변 확장 결과. */ - expansion: { - rounds: number; - /** 새로 추가한 셀에 적색이 없어 스스로 멈췄는가. */ - closed: boolean; - added_cells: number; - /** 확장 전(1차 영역) 셀 수. */ - initial_cells: number; - }; - /** 셀별 흐름 방향과 도로 도달 여부. 등고선이 없어 판정을 못하면 null. */ - flow: { - encoding: "base64-uint8"; - /** 방위 분해능(32). 코드 0 = 화면 오른쪽, 시계방향 증가. */ - azimuth_steps: number; - /** 제자리(더 낮은 이웃 없음)를 뜻하는 코드. */ - sink_code: number; - /** 표고가 없어 판정 못한 셀 코드. */ - invalid_code: number; - cells: number; - reaches_road: number; - no_road: number; - /** 격자에는 있으나 등고선 TIN 밖이라 표고가 없어 판정 못한 셀. */ - unanalyzed: number; - /** 확정된 상류 세류망을 따라 흐름을 강제로 새긴 셀 수. */ - burned: number; - outer_seeds: number; - interior_seeds: number; - /** 셀당 1바이트. 하위 6비트=32방위 코드(32=제자리, 33=무효), 0x80=도로 도달. - * 순서는 grid.row_spans를 행 → 구간 → 열 오름차순으로 훑은 순서와 같다. */ - data: string; - } | null; - /** 2차 전체 배수유역 외곽선(= 분수령). 적색 셀 전체의 외곽. */ - basin_polygon_lonlat: Array<[number, number]>; - basin_area_m2: number; - /** 도로 위 흐름 강도 — [누가거리 m, 그 구간으로 모이는 상류 면적 ㎡]. */ - strength_profile: Array<[number, number]>; - /** 기본 관 매설 위치 — 도로 × 세류선 교차점. */ - pipes: DrainageCandidate[]; - /** 영구저장소에 남긴 검증용 GeoJSON 경로. */ - saved_to: string | null; -} - -export async function fetchDrainagePrimaryRegion( - projectId: string, -): Promise { - // 등고선 하강 방향 + 적색 확장 루프까지 도는 요청이라 수십 초가 걸린다. - return requestJson( - `/projects/${projectId}/drainage/primary-region`, - { method: "GET" }, - API_ANALYSIS_TIMEOUT_MS, - ); -} - -export async function fetchDrainageCandidates( - projectId: string, -): Promise { - return requestJson(`/projects/${projectId}/drainage/candidates`, { - method: "GET", - }); -} - /** chainages를 주면 그 위치로 확정 산정하고, 비우면 자동 제안분으로 산정한다. */ export async function fetchDrainageBasins( projectId: string, diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py index c46b037a..4b75de22 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py @@ -1,309 +1,14 @@ -"""배수유역 산정 엔진. - -관 매설 구조물 측점 후보를 제안하고, 각 측점이 받는 배수유역 경계를 산정한다. -지형 판단은 **도엽 등고선·세류선(하천중심선)**만 사용한다 — 3D 포인트클라우드나 지형 -메시는 쓰지 않고(2026-07-28 사용자 지시), 표고점도 유효 데이터가 적어 뺐다(2026-07-31). -유역 경계 산정 자체는 격자 흐름 해석(`..._Engine_Watershed_Basin`)이 맡고, 이 모듈은 -측점 후보 제안과 노선 정점·누가거리 보간만 담당한다. +"""배수 관경 산정. 유역을 나누는 최종 목적은 각 지점의 파이프 관경 결정이다. 유역 경사면에 100년 강우빈도를 -적용해 모이는 물의 양을 산정하고 그 유량으로 관경을 정한다. 관경 수식은 아직 미확정이라 -`estimate_pipe_diameter_mm()`은 골격만 두고 비워 둔다. +적용해 모이는 물의 양을 산정하고 그 유량으로 관경을 정한다. + +노선 기하(정점·누가거리·세류 교차점)는 `common_util_route_geometry`로 옮겼다 — B04 분석과 +B05 세부 설계가 같은 표현을 써야 하기 때문이다(2026-07-31 구조 개편). """ from __future__ import annotations -import logging -import math -from dataclasses import dataclass, field -from typing import Any - -from shapely.geometry import LineString, Point, shape - -logger = logging.getLogger(__name__) - -# 구조물 측점 사이 최대 허용 간격(m). 세류 교차가 없어도 이 간격을 넘으면 절토부에 추가 배치한다. -MAX_STRUCTURE_SPACING_M = 300 -# 같은 세류 교차로 볼 최소 이격(m). 이보다 가까운 교차점은 하나로 묶는다. -MIN_STRUCTURE_SPACING_M = 5.0 -# 유역 경계 탐색 반경(m). 측점에서 이 거리를 넘는 지형은 해당 유역으로 보지 않는다. -MAX_BASIN_RADIUS_M = 1000.0 - - -@dataclass -class RouteVertex: - """노선 폴리라인의 한 점. chainage는 시점 기준 누가거리(m).""" - - x: float - y: float - z: float - chainage_m: float - - -@dataclass -class StructureCandidate: - """관 매설 구조물 측점 후보.""" - - chainage_m: float - x: float - y: float - # "stream"=세류 교차, "spacing"=300m 규칙에 따른 보충 배치 - reason: str - stream_name: str | None = None - - -@dataclass -class DrainageBasin: - """한 구조물 측점이 받는 배수유역.""" - - index: int - chainage_m: float - outlet_x: float - outlet_y: float - polygon_lonlat: list[list[float]] = field(default_factory=list) - area_m2: float = 0.0 - # 유역 최고 표고 − 측점 표고(m). 경사면 낙차. - relief_m: float = 0.0 - # 유하거리: 측점에서 유역 최상단까지 물길 길이(m). - flow_length_m: float = 0.0 - pipe_diameter_mm: float | None = None - - -def build_route_vertices(points: list[dict[str, Any]]) -> list[RouteVertex]: - """DB route_points 행을 누가거리가 채워진 정점 목록으로 바꾼다.""" - vertices: list[RouteVertex] = [] - cumulative = 0.0 - previous: tuple[float, float] | None = None - for row in points: - x = float(row["x"]) - y = float(row["y"]) - z = float(row.get("z") or 0.0) - if previous is not None: - cumulative += math.dist(previous, (x, y)) - chainage = row.get("chainage_m") - vertices.append( - RouteVertex( - x=x, - y=y, - z=z, - chainage_m=float(chainage) if chainage is not None else cumulative, - ) - ) - previous = (x, y) - return vertices - - -def _interpolate_vertex( - vertices: list[RouteVertex], chainage_m: float -) -> tuple[float, float, float]: - """누가거리 위치의 (x, y, z)를 선형 보간한다.""" - if not vertices: - return (0.0, 0.0, 0.0) - if chainage_m <= vertices[0].chainage_m: - return (vertices[0].x, vertices[0].y, vertices[0].z) - for previous, current in zip(vertices, vertices[1:]): - if chainage_m <= current.chainage_m: - span = current.chainage_m - previous.chainage_m - ratio = 0.0 if span <= 0 else (chainage_m - previous.chainage_m) / span - return ( - previous.x + (current.x - previous.x) * ratio, - previous.y + (current.y - previous.y) * ratio, - previous.z + (current.z - previous.z) * ratio, - ) - last = vertices[-1] - return (last.x, last.y, last.z) - - -def is_uphill_at(vertices: list[RouteVertex], chainage_m: float, window_m: float = 20.0) -> bool: - """해당 위치가 오르막(절토부)인지 판정한다. - - 내리막(성토부)은 물이 노선 바깥으로 흘러나가므로 배수유역을 만들지 않는다 - (2026-07-28 사용자 지시). 판정은 종단 계획선의 국소 기울기 부호로 한다. - """ - _, _, back_z = _interpolate_vertex(vertices, max(0.0, chainage_m - window_m)) - _, _, forward_z = _interpolate_vertex(vertices, chainage_m + window_m) - return forward_z >= back_z - - -def find_stream_crossings( - vertices: list[RouteVertex], - stream_features: list[dict[str, Any]], -) -> list[StructureCandidate]: - """노선 평면 선형과 세류선의 교차 지점을 찾는다.""" - if len(vertices) < 2: - return [] - route_line = LineString([(vertex.x, vertex.y) for vertex in vertices]) - candidates: list[StructureCandidate] = [] - for feature in stream_features: - geometry = feature.get("geometry") - if not geometry: - continue - try: - stream = shape(geometry) - except Exception: # noqa: BLE001 - 손상된 피처는 건너뛴다 - continue - if stream.is_empty: - continue - intersection = route_line.intersection(stream) - if intersection.is_empty: - continue - name = _stream_name(feature) - for point in _collect_points(intersection): - candidates.append( - StructureCandidate( - chainage_m=route_line.project(point), - x=point.x, - y=point.y, - reason="stream", - stream_name=name, - ) - ) - candidates.sort(key=lambda item: item.chainage_m) - return candidates - - -def _stream_name(feature: dict[str, Any]) -> str | None: - properties = feature.get("properties") or {} - for key in ("명칭", "하천명", "NAME", "name"): - value = properties.get(key) - if value: - return str(value) - return None - - -def _collect_points(geometry: Any) -> list[Point]: - """교차 결과(Point/MultiPoint/LineString 등)에서 대표 점들을 뽑는다.""" - if geometry.geom_type == "Point": - return [geometry] - if geometry.geom_type in {"MultiPoint", "GeometryCollection"}: - points: list[Point] = [] - for part in geometry.geoms: - points.extend(_collect_points(part)) - return points - # 선분끼리 겹쳐 선으로 나온 경우는 중점을 대표로 쓴다. - if geometry.geom_type in {"LineString", "MultiLineString"}: - return [geometry.interpolate(0.5, normalized=True)] - return [] - - -def propose_structure_stations( - vertices: list[RouteVertex], - stream_features: list[dict[str, Any]], -) -> list[StructureCandidate]: - """구조물 측점 후보를 제안한다. - - ① 세류 교차 지점 ② 내리막(성토부) 제외 ③ 직전 측점에서 300m 초과 시 절토부에 보충 배치. - """ - if len(vertices) < 2: - return [] - total_length = vertices[-1].chainage_m - crossings = [ - candidate - for candidate in find_stream_crossings(vertices, stream_features) - if is_uphill_at(vertices, candidate.chainage_m) - ] - - # 너무 가까운 교차는 하나로 본다(같은 계곡을 여러 선분이 지나는 경우). - merged: list[StructureCandidate] = [] - for candidate in crossings: - if merged and candidate.chainage_m - merged[-1].chainage_m < MIN_STRUCTURE_SPACING_M: - continue - merged.append(candidate) - - # 300m 규칙: 빈 구간에 절토부 지점을 찾아 보충한다. - filled: list[StructureCandidate] = [] - previous_chainage = 0.0 - for candidate in [*merged, None]: - boundary = candidate.chainage_m if candidate else total_length - filled.extend(_fill_spacing(vertices, previous_chainage, boundary)) - if candidate: - filled.append(candidate) - previous_chainage = candidate.chainage_m - else: - previous_chainage = boundary - filled.sort(key=lambda item: item.chainage_m) - return filled - - -def _fill_spacing( - vertices: list[RouteVertex], - start_m: float, - end_m: float, -) -> list[StructureCandidate]: - """[start, end] 구간이 300m를 넘으면 보충 측점을 만든다. - - 종단도상 상대적으로 물이 모일 것으로 예상되는 지점(절토부 내 종단 저점)을 - 우선 배치한다(2026-07-29 사용자 지시). 저점이 없으면 목표 인근 절토부로 대체한다. - """ - added: list[StructureCandidate] = [] - cursor = start_m - while end_m - cursor > MAX_STRUCTURE_SPACING_M: - target = cursor + MAX_STRUCTURE_SPACING_M - placed = _gather_low_point(vertices, cursor, target, end_m) - if placed is None: - placed = _nearest_uphill(vertices, target, end_m) - if placed is None: - # 도로 연장 기준 300m 규칙 — 저점·절토부가 없어도 관 배치는 보장한다 - # (2026-07-29 사용자 지시: 도로 340m면 최소 1개). - placed = min(target, (cursor + end_m) / 2.0) - x, y, _ = _interpolate_vertex(vertices, placed) - added.append(StructureCandidate(chainage_m=placed, x=x, y=y, reason="spacing")) - cursor = placed - return added - - -def _gather_low_point( - vertices: list[RouteVertex], - cursor_m: float, - target_m: float, - limit_m: float, - step_m: float = 10.0, -) -> float | None: - """탐색창 [cursor+150, target] 안 절토부의 종단 국소 저점(사그) 중 가장 낮은 지점. - - 창 하한을 간격의 절반으로 두어 보충 측점이 과밀하게 몰리지 않게 하고, - 국소 저점만 인정해 일정 오르막에서는 None(300m 규칙 폴백)을 돌려준다. - """ - window_start = cursor_m + MAX_STRUCTURE_SPACING_M / 2.0 - probes: list[float] = [] - probe = window_start - step_m - while probe <= target_m + step_m: - probes.append(probe) - probe += step_m - heights = [_interpolate_vertex(vertices, position)[2] for position in probes] - best: tuple[float, float] | None = None # (계획고 z, 누가거리) - for i in range(1, len(probes) - 1): - position = probes[i] - if position >= limit_m or position > target_m or position < window_start: - continue - # 국소 저점(양쪽이 같거나 높음) = 물이 모여 더 못 흐르는 지점. 앞쪽이 오르막인 - # 조건을 내포하므로 별도의 절토부(is_uphill_at) 판정은 두지 않는다. - if heights[i] > heights[i - 1] or heights[i] > heights[i + 1]: - continue - if best is None or heights[i] < best[0]: - best = (heights[i], position) - return best[1] if best else None - - -def _nearest_uphill( - vertices: list[RouteVertex], - target_m: float, - limit_m: float, - step_m: float = 10.0, -) -> float | None: - """목표 위치에서 가장 가까운 절토부(오르막) 지점을 찾는다. 없으면 None.""" - if is_uphill_at(vertices, target_m): - return target_m - offset = step_m - while offset <= MAX_STRUCTURE_SPACING_M / 2: - for probe in (target_m - offset, target_m + offset): - if probe <= 0 or probe >= limit_m: - continue - if is_uphill_at(vertices, probe): - return probe - offset += step_m - return None - def estimate_pipe_diameter_mm( area_m2: float, diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py new file mode 100644 index 00000000..51be65f9 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py @@ -0,0 +1,441 @@ +"""배수유역 세부 설계 (B05 — 일반 사용자용). + +**분석은 하지 않는다.** B04가 미리 돌려 저장한 결과를 읽어, 사용자가 실제로 손대는 두 가지만 +처리한다(2026-07-31 사용자 지시). + + ⑨ 관 간격이 최대치를 넘는 구간에 **최소 개수**로 관을 보충 + ⑩ 측구 흐름으로 도로 셀 → 담당 관을 정하고, 셀이 도달한 도로 셀의 담당 관을 그대로 + 그 셀의 유역 번호로 삼아 세부유역을 나눈다 + ⑪ 사용자가 관을 옮기거나 추가하면 ⑩만 다시 돈다 — 격자 해석은 재사용한다 + +읽어 오는 것(`B04_wf1_Surface/drainage/`): + · `03_road_routing.geojson` — 계획도로선 · 기본 배관 · 2차 전체 배수유역 + · `03_road_routing.npz` — 셀 → 도로 셀 귀속, 유하장, 강도, 도로 셀 제원, 셀 표고 +화살표(방향 코드)나 밴드 표고 같은 관리자 확인용 배열은 읽지 않는다 — 여기서는 필요 없고 +파일만 무거워진다. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, field +from pathlib import Path + +import numpy as np + +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Export import STAGES, drainage_dir +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Flow import largest_ring, polygonize_labels +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import GridSpec +from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import estimate_pipe_diameter_mm +from common_util.common_util_route_geometry import ( + RouteVertex, + StructureCandidate, + interpolate_vertex, + is_uphill_at, +) +from config.config_system import ( + DRAINAGE_DITCH_SAMPLE_M, + DRAINAGE_PIPE_MAX_SPACING_M, + DRAINAGE_PIPE_MIN_SPACING_M, +) + +logger = logging.getLogger(__name__) + +# 관 위치 선정 점수 배분 — 흐름 강도가 주, 종단 저점이 보조. +_SCORE_WEIGHT_STRENGTH = 0.7 +_SCORE_WEIGHT_SAG = 0.3 +# 성토부(내리막)는 물이 노선 밖으로 빠지므로 관 위치로 덜 선호한다. +_SCORE_FILL_PENALTY = 0.5 + + +@dataclass +class DrainageDetail: + """B05 산출물 — 화면에 그릴 기하와 세부유역.""" + + route_lonlat: list[list[float]] = field(default_factory=list) + basin_lonlat: list[list[float]] = field(default_factory=list) + pipes: list[StructureCandidate] = field(default_factory=list) + basins: list[WatershedBasin] = field(default_factory=list) + grid_cell_m: float = 1.0 + + +def build_drainage_detail( + stored_path: str, + vertices: list[RouteVertex], + confirmed_chainages: list[float] | None = None, +) -> DrainageDetail | None: + """B04 분석 결과를 읽어 관을 보충하고 세부유역을 나눈다. + + `confirmed_chainages`를 주면 그 위치를 관으로 확정하고(사용자 편집), 비우면 B04의 + 기본 관에 최대 간격 규칙으로 최소 개수만 보충한다. 어느 쪽이든 격자 해석은 하지 않는다. + """ + routing = load_road_routing(stored_path) + if routing is None or len(vertices) < 2: + return None + + if confirmed_chainages: + pipes = _pipes_from_chainages(vertices, confirmed_chainages) + else: + # 저장분의 기본 관은 누가거리만 신뢰한다 — 좌표는 현재 노선 위로 다시 찍는다. + base = [ + StructureCandidate( + chainage_m=pipe.chainage_m, + x=interpolate_vertex(vertices, pipe.chainage_m)[0], + y=interpolate_vertex(vertices, pipe.chainage_m)[1], + reason=pipe.reason, + ) + for pipe in routing.base_pipes + ] + pipes = place_pipes(vertices, base, routing.strength_curve) + + detail = DrainageDetail( + route_lonlat=routing.route_lonlat, + basin_lonlat=routing.basin_lonlat, + pipes=pipes, + grid_cell_m=routing.spec.cell_m, + ) + if not pipes: + return detail + pipe_of_slot = assign_road_cells_to_pipes(vertices, pipes, routing.road_chainage) + detail.basins = assemble_basins(routing, pipes, pipe_of_slot) + logger.info( + "배수유역: 세부 설계 — 관 %d개(기본 %d + 보충 %d), 세부유역 %d개", + len(pipes), + sum(1 for pipe in pipes if pipe.reason != "spacing"), + sum(1 for pipe in pipes if pipe.reason == "spacing"), + len(detail.basins), + ) + return detail + + +@dataclass +class WatershedBasin: + """관 하나가 받는 세부 배수유역.""" + + index: int + chainage_m: float + outlet_x: float + outlet_y: float + boundary_xy: list[tuple[float, float]] = field(default_factory=list) + area_m2: float = 0.0 + relief_m: float = 0.0 + flow_length_m: float = 0.0 + pipe_diameter_mm: float | None = None + + +@dataclass +class RoadRouting: + """B04가 남긴 배수유역 분석 결과 — B05가 세부유역을 나누는 데 필요한 최소 묶음.""" + + spec: GridSpec + # (R*C,) int32 — 셀이 물길을 따라 도달하는 도로 셀 슬롯(−1 = 미도달). + road_slot: np.ndarray + path_length: np.ndarray # (R*C,) float32 — 그 도로 셀까지 물길 길이(m) + elevation: np.ndarray # (R*C,) float32 — 셀 표고(유역 낙차 계산용) + road_cell_index: np.ndarray # (K,) int32 — 도로 셀의 평탄 인덱스 + road_chainage: np.ndarray # (K,) float64 — 도로 셀의 누가거리(m) + strength: np.ndarray # (K,) int64 — 도로 셀별 상류 셀 수 + # 화면에 그대로 그릴 기하(WGS84 lon/lat). + route_lonlat: list[list[float]] = field(default_factory=list) + basin_lonlat: list[list[float]] = field(default_factory=list) + base_pipes: list[StructureCandidate] = field(default_factory=list) + + @property + def strength_curve(self) -> np.ndarray: + """누가거리 1m 구간별 유입 면적(㎡) 곡선 — 관 보충 위치 점수의 근거.""" + if self.road_chainage.size == 0: + return np.zeros(1) + bins = max(1, int(np.ceil(self.road_chainage.max())) + 1) + index = np.clip(np.round(self.road_chainage).astype(np.int64), 0, bins - 1) + weights = self.strength.astype(np.float64) * self.spec.cell_area_m2 + return np.bincount(index, weights=weights, minlength=bins) + + +def load_road_routing(stored_path: str) -> RoadRouting | None: + """B04가 남긴 `03_road_routing` 산출물을 읽는다. 없으면 None.""" + directory = drainage_dir(stored_path) + prefix = STAGES["road_routing"] + array_path = directory / f"{prefix}_road_routing.npz" + if not array_path.exists(): + logger.warning("배수유역: B04 분석 결과가 없습니다 (%s).", array_path) + return None + try: + with np.load(array_path, allow_pickle=False) as data: + spec = GridSpec( + x_min=float(data["x_min"]), + y_max=float(data["y_max"]), + cell_m=float(data["cell_m"]), + n_rows=int(data["n_rows"]), + n_cols=int(data["n_cols"]), + ) + routing = RoadRouting( + spec=spec, + road_slot=data["road_slot"].reshape(-1), + path_length=data["path_length"].reshape(-1), + elevation=data["elevation"].reshape(-1), + road_cell_index=data["road_cell_index"], + road_chainage=data["road_chainage"], + strength=data["strength"], + ) + except (OSError, KeyError, ValueError): + logger.warning("배수유역: B04 분석 결과를 읽지 못했습니다 (%s).", array_path) + return None + + _read_geometry(directory / f"{prefix}_road_routing.geojson", routing) + logger.info( + "배수유역: B04 결과 로드 — 격자 %d×%d, 도로 셀 %d, 기본 관 %d", + spec.n_rows, + spec.n_cols, + routing.road_cell_index.size, + len(routing.base_pipes), + ) + return routing + + +def _read_geometry(path: Path, routing: RoadRouting) -> None: + """계획도로선·2차 유역 외곽선·기본 관을 GeoJSON에서 읽어 채운다.""" + if not path.exists(): + logger.warning("배수유역: B04 기하 산출물이 없습니다 (%s).", path) + return + try: + with path.open("r", encoding="utf-8") as file: + document = json.load(file) + except (OSError, json.JSONDecodeError): + logger.warning("배수유역: B04 기하 산출물을 읽지 못했습니다 (%s).", path) + return + for feature in document.get("features", []): + properties = feature.get("properties") or {} + geometry = feature.get("geometry") or {} + coordinates = geometry.get("coordinates") + kind = properties.get("kind") + if kind == "route" and geometry.get("type") == "LineString": + routing.route_lonlat = coordinates + elif kind == "basin_boundary" and geometry.get("type") == "Polygon" and coordinates: + routing.basin_lonlat = coordinates[0] + elif kind == "pipe" and geometry.get("type") == "Point": + routing.base_pipes.append( + StructureCandidate( + chainage_m=float(properties.get("chainage_m") or 0.0), + x=0.0, + y=0.0, + reason=str(properties.get("reason") or "stream"), + ) + ) + + +# ── ⑨ 관 최소 개수 보충 ───────────────────────────────────────────────────── + + +def place_pipes( + vertices: list[RouteVertex], + base_pipes: list[StructureCandidate], + strength_curve: np.ndarray, +) -> list[StructureCandidate]: + """B04가 정한 기본 관(세류 교차점)에, 최대 간격을 넘는 구간만 최소 개수로 보충한다. + + 기본 관은 여기서 다시 찾지 않는다 — B04 산출물에 이미 들어 있다. + """ + total_length = vertices[-1].chainage_m + base: list[StructureCandidate] = [] + for candidate in sorted(base_pipes, key=lambda item: item.chainage_m): + if base and candidate.chainage_m - base[-1].chainage_m < DRAINAGE_PIPE_MIN_SPACING_M: + continue + base.append(candidate) + + filled: list[StructureCandidate] = [] + previous = 0.0 + for candidate in [*base, None]: + boundary = candidate.chainage_m if candidate else total_length + filled.extend(_fill_gap(vertices, strength_curve, previous, boundary)) + if candidate: + filled.append(candidate) + previous = candidate.chainage_m + else: + previous = boundary + filled.sort(key=lambda item: item.chainage_m) + return filled + + +def _fill_gap( + vertices: list[RouteVertex], + strength_curve: np.ndarray, + start_m: float, + end_m: float, +) -> list[StructureCandidate]: + """[start, end] 구간에 최대 간격을 지키는 **최소 개수**의 관을 배치한다. + + 필요 개수 n은 구간 길이로 정해지고(ceil(L/max) − 1), 각 관은 등분 위치를 중심으로 + 허용 여유(slack) 안에서만 움직인다. 그래서 개수는 늘지 않으면서도 흐름 강도가 크고 + 종단이 낮은 지점으로 붙는다. + """ + span = end_m - start_m + if span <= DRAINAGE_PIPE_MAX_SPACING_M: + return [] + count = int(np.ceil(span / DRAINAGE_PIPE_MAX_SPACING_M)) - 1 + if count <= 0: + return [] + spacing = span / (count + 1) + slack = max(0.0, (DRAINAGE_PIPE_MAX_SPACING_M - spacing) / 2.0) + placed: list[StructureCandidate] = [] + for order in range(1, count + 1): + nominal = start_m + spacing * order + low = max(start_m + DRAINAGE_PIPE_MIN_SPACING_M, nominal - slack) + high = min(end_m - DRAINAGE_PIPE_MIN_SPACING_M, nominal + slack) + chosen = _best_position(vertices, strength_curve, low, high, nominal) + x, y, _ = interpolate_vertex(vertices, chosen) + placed.append(StructureCandidate(chainage_m=chosen, x=x, y=y, reason="spacing")) + return placed + + +def _best_position( + vertices: list[RouteVertex], + strength_curve: np.ndarray, + low_m: float, + high_m: float, + fallback_m: float, +) -> float: + """허용 구간 안에서 흐름 강도가 크고 종단이 낮은 위치를 고른다.""" + if high_m <= low_m: + return fallback_m + positions = np.arange(low_m, high_m + 1.0, 1.0) + if positions.size == 0: + return fallback_m + index = np.clip(np.round(positions).astype(np.int64), 0, strength_curve.size - 1) + strength = strength_curve[index] + heights = np.array([interpolate_vertex(vertices, float(p))[2] for p in positions]) + + strength_score = strength / strength.max() if strength.max() > 0 else np.zeros_like(strength) + height_span = float(heights.max() - heights.min()) + sag_score = ( + (heights.max() - heights) / height_span if height_span > 1e-6 else np.zeros_like(heights) + ) + score = _SCORE_WEIGHT_STRENGTH * strength_score + _SCORE_WEIGHT_SAG * sag_score + for order, position in enumerate(positions): + if not is_uphill_at(vertices, float(position)): + score[order] *= _SCORE_FILL_PENALTY + return float(positions[int(np.argmax(score))]) + + +def _pipes_from_chainages( + vertices: list[RouteVertex], chainages: list[float] +) -> list[StructureCandidate]: + """사용자가 확정·편집한 누가거리 목록을 관 후보로 되돌린다. + + 노선 밖 값은 시·종점으로 당긴다. 그대로 두면 마커는 끝점에 찍히는데 라벨만 −50m처럼 + 나와 좌표와 표기가 어긋난다. + """ + total_length = vertices[-1].chainage_m + clamped = {min(max(round(float(item), 2), 0.0), total_length) for item in chainages} + pipes: list[StructureCandidate] = [] + for value in sorted(clamped): + x, y, _ = interpolate_vertex(vertices, value) + pipes.append(StructureCandidate(chainage_m=value, x=x, y=y, reason="confirmed")) + return pipes + + +# ── ⑩ 측구 흐름으로 도로 셀 → 담당 관 ─────────────────────────────────────── + + +def assign_road_cells_to_pipes( + vertices: list[RouteVertex], + pipes: list[StructureCandidate], + road_chainage: np.ndarray, +) -> np.ndarray: + """도로 셀마다 물이 실제로 흘러가는 담당 관 번호를 정한다. + + 노면 물은 측구를 타고 종단 내리막으로 흐르므로, 종단 계획선을 1차원 지형으로 보고 + 같은 방식(내리막 추적 + 관에서 흡수)으로 푼다. 관이 없는 사그(저점)에 갇힌 구간은 + 가장 가까운 관이 받는 것으로 본다. + """ + total_length = vertices[-1].chainage_m + step = max(DRAINAGE_DITCH_SAMPLE_M, 0.5) + stations = np.arange(0.0, total_length + step, step) + heights = np.array([interpolate_vertex(vertices, float(s))[2] for s in stations]) + pipe_chainages = np.array([pipe.chainage_m for pipe in pipes]) + pipe_station = np.clip(np.round(pipe_chainages / step).astype(np.int64), 0, stations.size - 1) + + # 앞뒤 이웃 중 더 낮은 쪽으로 흘려보낸다(양쪽 다 높으면 사그 = 제자리). + back_z = np.full(stations.size, np.inf) + back_z[1:] = heights[:-1] + forward_z = np.full(stations.size, np.inf) + forward_z[:-1] = heights[1:] + go_back = (back_z < heights) & (back_z <= forward_z) + go_forward = (forward_z < heights) & ~go_back + receiver = np.arange(stations.size, dtype=np.int64) + receiver[go_back] -= 1 + receiver[go_forward] += 1 + receiver[pipe_station] = pipe_station # 관은 물을 흡수한다 + + owner = np.full(stations.size, -1, dtype=np.int64) + owner[pipe_station] = np.arange(pipe_chainages.size) + jump = receiver + for _ in range(40): + next_jump = jump[jump] + if np.array_equal(next_jump, jump): + break + jump = next_jump + resolved = owner[jump] + # 관 없는 사그에 갇힌 구간은 가장 가까운 관에 붙인다. + orphan = resolved < 0 + if orphan.any() and pipe_chainages.size: + nearest = np.abs(stations[orphan, None] - pipe_chainages[None, :]).argmin(axis=1) + resolved[orphan] = nearest + + slot_station = np.clip(np.round(road_chainage / step).astype(np.int64), 0, stations.size - 1) + return resolved[slot_station].astype(np.int32) + + +# ── ⑩ 세부유역 조립 ──────────────────────────────────────────────────────── + + +def assemble_basins( + solution: RoadRouting, + pipes: list[StructureCandidate], + pipe_of_slot: np.ndarray, +) -> list[WatershedBasin]: + """셀이 도달한 도로 셀의 담당 관을 그대로 유역 번호로 삼아 세부유역을 만든다.""" + spec = solution.spec + labels = np.full(spec.size, -1, dtype=np.int32) + reached = solution.road_slot >= 0 + labels[reached] = pipe_of_slot[solution.road_slot[reached]] + + polygons = polygonize_labels(spec, labels) + cell_area = spec.cell_area_m2 + basins: list[WatershedBasin] = [] + for order, pipe in enumerate(pipes): + member = labels == order + count = int(member.sum()) + if count == 0: + continue + geometry = polygons.get(order) + elevations = solution.elevation[member] + highest = float(np.nanmax(elevations)) if np.isfinite(elevations).any() else 0.0 + outlet_z = _outlet_elevation(solution, order, pipe_of_slot) + area = count * cell_area + relief = max(0.0, highest - outlet_z) + flow_length = float(solution.path_length[member].max()) + basins.append( + WatershedBasin( + index=len(basins) + 1, + chainage_m=pipe.chainage_m, + outlet_x=pipe.x, + outlet_y=pipe.y, + boundary_xy=largest_ring(geometry) if geometry is not None else [], + area_m2=area, + relief_m=relief, + flow_length_m=flow_length, + pipe_diameter_mm=estimate_pipe_diameter_mm(area, relief, flow_length), + ) + ) + return basins + + +def _outlet_elevation(solution: RoadRouting, pipe_order: int, pipe_of_slot: np.ndarray) -> float: + """관이 담당하는 도로 셀들의 최저 표고 = 유역 출구 표고.""" + slots = np.flatnonzero(pipe_of_slot == pipe_order) + if slots.size == 0: + return 0.0 + elevations = solution.elevation[solution.road_cell_index[slots]] + finite = elevations[np.isfinite(elevations)] + return float(finite.min()) if finite.size else 0.0 diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py deleted file mode 100644 index 128ca2d4..00000000 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py +++ /dev/null @@ -1,723 +0,0 @@ -"""배수유역 산정 오케스트레이터 — 격자 해석 · 관 배치 · 세부유역 조립. - -전체 흐름 - ① 등고선 정리 → 상류 세류선 추출 → 1차 격자 범위(반경 버퍼 bbox) - ② 격자 지형 해석(TIN 보간 · 채움 · D8) → 도로에서 상류 추적 - ③ 활성 셀이 격자 최외곽에 닿으면 그 방향으로만 넓혀 다시 해석 (경계 링이 전부 - 비활성이 되면 정지 — 하드 반경 상한이 아니라 흐름 자체가 종료 조건이다) - ④ 도로 셀별 흐름 강도(상류 셀 수) 산출 → 2차 전체 배수유역 외곽선 확정 - ⑤ 관 배치: 세류 교차점이 기본, 간격이 최대치를 넘으면 흐름 강도·종단 저점을 보고 - **최소 개수**만 보충 - ⑥ 측구 흐름(종단 내리막)으로 도로 셀 → 담당 관을 정하고, 셀이 도달한 도로 셀의 - 담당 관을 그대로 그 셀의 유역 번호로 삼아 세부유역을 나눈다 - -②~④는 관 배치와 무관하므로 `.npz`로 캐시한다. 사용자가 관을 옮기거나 추가하면 -⑥만 다시 돌면 되고 격자 해석은 재사용한다. -""" - -from __future__ import annotations - -import hashlib -import logging -import time -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -import numpy as np -from shapely.geometry import LineString - -from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import ( - RouteVertex, - StructureCandidate, - _interpolate_vertex, - estimate_pipe_diameter_mm, - find_stream_crossings, - is_uphill_at, -) -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Descent import ContourDescent -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Expand import expand_by_red_boundary -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Flow import ( - FlowClassification, - RoadRaster, - expand_until_closed, - largest_ring, - outer_boundary, - polygonize_labels, - trace_flow, -) -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import ( - GridSpec, - TerrainGrid, - build_contour_cloud, - route_elevation_floor, -) -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Stream import ( - PrimaryRegion, - build_primary_region, -) -from config.config_system import ( - DRAINAGE_DITCH_SAMPLE_M, - DRAINAGE_EXPAND_STEP_M, - DRAINAGE_GRID_SIZE_M, - DRAINAGE_INITIAL_RADIUS_M, - DRAINAGE_MAX_EXPAND_ROUNDS, - DRAINAGE_PIPE_MAX_SPACING_M, - DRAINAGE_PIPE_MIN_SPACING_M, - DRAINAGE_ROAD_WIDTH_M, -) - -logger = logging.getLogger(__name__) - -# 강도 곡선 응답 간격(m). 도로 위 흐름 강도 히트 표기는 이 간격으로 내보낸다. -_STRENGTH_OUTPUT_STEP_M = 5.0 -# 관 위치 선정 점수 배분 — 흐름 강도가 주, 종단 저점이 보조. -_SCORE_WEIGHT_STRENGTH = 0.7 -_SCORE_WEIGHT_SAG = 0.3 -# 성토부(내리막)는 물이 노선 밖으로 빠지므로 관 위치로 덜 선호한다. -_SCORE_FILL_PENALTY = 0.5 - - -@dataclass -class WatershedBasin: - """관 하나가 받는 세부 배수유역.""" - - index: int - chainage_m: float - outlet_x: float - outlet_y: float - boundary_xy: list[tuple[float, float]] = field(default_factory=list) - area_m2: float = 0.0 - relief_m: float = 0.0 - flow_length_m: float = 0.0 - pipe_diameter_mm: float | None = None - - -@dataclass -class WatershedResult: - """배수유역 산정 결과 일체.""" - - basins: list[WatershedBasin] = field(default_factory=list) - pipes: list[StructureCandidate] = field(default_factory=list) - # 2차 전체 배수유역 외곽선(= 분수령). 세부유역 경계는 이 안쪽에서만 그어진다. - main_boundary_xy: list[tuple[float, float]] = field(default_factory=list) - # 도로 위 흐름 강도 곡선 — (누가거리 m, 그 지점으로 모이는 상류 면적 ㎡). - strength_profile: list[tuple[float, float]] = field(default_factory=list) - grid_cell_m: float = DRAINAGE_GRID_SIZE_M - - -@dataclass -class _GridSolution: - """관 배치와 무관한 격자 해석 결과 묶음(캐시 대상).""" - - spec: GridSpec - elevation: np.ndarray # (R*C,) float32 - road_cell_index: np.ndarray # (K,) int32 - road_chainage: np.ndarray # (K,) float64 - road_slot: np.ndarray # (R*C,) int32 — 셀이 도달한 도로 셀 슬롯(−1=미도달) - path_length: np.ndarray # (R*C,) float32 - strength: np.ndarray # (K,) int64 - active: np.ndarray # (R*C,) bool - signature: str - - -# ── 진입점 ────────────────────────────────────────────────────────────────── - - -def build_drainage_watershed( - vertices: list[RouteVertex], - contour_features: list[dict[str, Any]], - stream_features: list[dict[str, Any]], - confirmed_chainages: list[float] | None = None, - cache_path: Path | None = None, -) -> WatershedResult: - """배수유역과 관 배치를 산정한다. - - `confirmed_chainages`를 주면 그 위치를 관으로 확정하고(사용자 편집), 비우면 세류 - 교차 + 최소 보충으로 자동 배치한다. 두 경우 모두 격자 해석은 캐시를 재사용한다. - """ - if len(vertices) < 2: - return WatershedResult() - route_line = LineString([(vertex.x, vertex.y) for vertex in vertices]) - solution = _solve_grid(vertices, route_line, contour_features, stream_features, cache_path) - if solution is None or solution.road_cell_index.size == 0: - return WatershedResult() - - strength_area = solution.strength.astype(np.float64) * solution.spec.cell_area_m2 - strength_curve = _strength_by_chainage(solution.road_chainage, strength_area, route_line.length) - - if confirmed_chainages: - pipes = _pipes_from_chainages(vertices, confirmed_chainages) - else: - pipes = _place_pipes(vertices, stream_features, strength_curve) - if not pipes: - return WatershedResult( - main_boundary_xy=_main_boundary(solution), - strength_profile=_downsample_strength(strength_curve), - grid_cell_m=solution.spec.cell_m, - ) - - pipe_of_slot = _assign_road_cells_to_pipes(vertices, pipes, solution.road_chainage) - basins = _assemble_basins(solution, pipes, pipe_of_slot) - return WatershedResult( - basins=basins, - pipes=pipes, - main_boundary_xy=_main_boundary(solution), - strength_profile=_downsample_strength(strength_curve), - grid_cell_m=solution.spec.cell_m, - ) - - -# ── ①~② 1차 배수유역 (단계 검증 대상) ────────────────────────────────────── - - -def resolve_primary_region( - vertices: list[RouteVertex], - route_line: LineString, - contour_features: list[dict[str, Any]], - stream_features: list[dict[str, Any]], -) -> PrimaryRegion | None: - """도로 교차 세류선(상류측)과 노선을 반경 버퍼한 1차 배수유역과 격자 범위를 정한다. - - 상·하류 판정에 쓸 등고선은 노선 주변만 있으면 된다(교차점이 전부 노선 위이므로). - 도엽 전체를 읽으면 이 단계에서만 수십 초가 날아간다. - """ - floor = route_elevation_floor([vertex.z for vertex in vertices]) - near_bounds = route_line.buffer(DRAINAGE_INITIAL_RADIUS_M * 2.0).bounds - cloud = build_contour_cloud(contour_features, floor, near_bounds) - if cloud.is_empty: - logger.warning("배수유역: 노선 주변에 등고선이 없어 1차 영역을 정할 수 없습니다.") - return None - return build_primary_region( - route_line, stream_features, cloud, DRAINAGE_INITIAL_RADIUS_M, DRAINAGE_GRID_SIZE_M - ) - - -def preview_primary_region( - vertices: list[RouteVertex], - contour_features: list[dict[str, Any]], - stream_features: list[dict[str, Any]], -) -> PrimaryRegion | None: - """단계 검증용 — TIN·흐름 계산 없이 1차 배수유역 근거만 뽑는다.""" - if len(vertices) < 2: - return None - route_line = LineString([(vertex.x, vertex.y) for vertex in vertices]) - return resolve_primary_region(vertices, route_line, contour_features, stream_features) - - -@dataclass -class StagePreview: - """단계 검증 산출물 묶음. 기능을 붙일 때마다 여기에 항목이 하나씩 늘어난다. - - 확장을 거치면 격자와 해석 영역이 1차 영역보다 커진다. 화면·저장은 `region.spec`이 - 아니라 여기 `spec`/`domain`을 봐야 한다. - """ - - region: PrimaryRegion - spec: GridSpec | None = None - domain: np.ndarray | None = None - terrain: TerrainGrid | None = None - road: RoadRaster | None = None - flow: FlowClassification | None = None - descent: ContourDescent | None = None - expand_rounds: int = 0 - expand_closed: bool = False - expand_added_cells: int = 0 - # ⑥ 도로 위 흐름 강도 — (누가거리 m, 그 구간으로 모이는 상류 면적 ㎡). - strength_profile: list[tuple[float, float]] = field(default_factory=list) - # ⑦ 2차 전체 배수유역 외곽선(= 분수령). 적색 셀 전체를 폴리곤화한 것. - basin_boundary_xy: list[tuple[float, float]] = field(default_factory=list) - basin_area_m2: float = 0.0 - # ⑧ 기본 관 매설 위치 — 도로 × 세류선 교차점. - pipes: list[StructureCandidate] = field(default_factory=list) - - -def preview_stages( - vertices: list[RouteVertex], - contour_features: list[dict[str, Any]], - stream_features: list[dict[str, Any]], -) -> StagePreview | None: - """지금까지 구현·검증된 단계를 순서대로 돌려 결과를 모은다. - - 현재 포함: ① 1차 배수유역 ② 격자 생성 ③ **등고선 하강 방향** ④ 도로 도달 판정 - ⑤ **최외곽 적색 셀 주변 확장**. - - ③은 보간면(TIN)을 쓰지 않는다. 높은 등고 라인에서 낮은 등고 라인으로 내려가며 방향을 - 세우므로 가짜 웅덩이·평탄면이 원리적으로 생기지 않는다(2026-07-31 사용자 지시로 방식 교체). - - ⑤는 최외곽에 적색이 남아 있으면 그 주변으로 넓혀 다시 분석하고, **새로 추가한 셀에 - 적색이 없으면** 멈춘다. - """ - if len(vertices) < 2: - return None - route_line = LineString([(vertex.x, vertex.y) for vertex in vertices]) - region = resolve_primary_region(vertices, route_line, contour_features, stream_features) - if region is None: - return None - - started = time.perf_counter() - floor = route_elevation_floor([vertex.z for vertex in vertices]) - expansion = expand_by_red_boundary( - region.spec, - region.cell_mask, - contour_features, - route_line, - region.split.upstream, - floor, - ) - if expansion is None: - logger.warning("배수유역: 등고선 하강 방향을 세우지 못해 흐름 판정을 건너뜁니다.") - return StagePreview(region=region) - - analysis = expansion.analysis - spec = analysis.spec - red = analysis.flow.reaches_road & analysis.flow.analyzed - - # ⑥ 흐름 강도 — 셀마다 물이 실제로 들어가는 도로 셀을 구해 도로 셀별로 센다. - # 색 판정은 세류 셀에서 멈추지만(거기서 도달이 확정되므로), 강도는 그 물이 세류를 타고 - # 최종적으로 어느 도로 셀로 들어가는지를 봐야 하므로 도로만 흡수점으로 두고 다시 따라간다. - strength_curve = _preview_strength(analysis, red, route_line.length) - - # ⑦ 2차 전체 배수유역 외곽선 = 적색 셀 전체의 외곽. - boundary = outer_boundary(spec, red.reshape(spec.n_rows, spec.n_cols)) - basin_ring = largest_ring(boundary) if boundary is not None else [] - - # ⑧ 기본 관 매설 위치 = 도로 × 세류선 교차점(너무 가까운 것은 하나로 합침). - pipes = _base_pipes(vertices, stream_features) - - logger.info( - "배수유역: 단계 분석 %.1fs (확장 %d회, 최종 셀 %d개) — " - "2차 유역 %.0f㎡, 기본 관 %d개, 강도 곡선 %d점", - time.perf_counter() - started, - expansion.rounds, - spec.size, - int(red.sum()) * spec.cell_area_m2, - len(pipes), - int((strength_curve > 0).sum()), - ) - return StagePreview( - region=region, - spec=spec, - domain=analysis.domain, - terrain=analysis.terrain, - road=analysis.road, - flow=analysis.flow, - descent=analysis.descent, - expand_rounds=expansion.rounds, - expand_closed=expansion.closed, - expand_added_cells=expansion.added_cells, - strength_profile=_downsample_strength(strength_curve), - basin_boundary_xy=basin_ring, - basin_area_m2=int(red.sum()) * spec.cell_area_m2, - pipes=pipes, - ) - - -def _preview_strength(analysis: Any, red: np.ndarray, route_length_m: float) -> np.ndarray: - """적색 셀이 실제로 들어가는 도로 셀을 세어 누가거리별 유입 면적 곡선을 만든다.""" - road = analysis.road - if road.count == 0: - return np.zeros(1) - routed = trace_flow(analysis.terrain, road) - slots = routed.road_slot - counted = red & (slots >= 0) - strength = np.bincount(slots[counted], minlength=road.count).astype(np.float64) - return _strength_by_chainage( - road.chainage, strength * analysis.spec.cell_area_m2, route_length_m - ) - - -def _base_pipes( - vertices: list[RouteVertex], stream_features: list[dict[str, Any]] -) -> list[StructureCandidate]: - """도로 × 세류선 교차점을 기본 관 위치로 삼는다. 300m 보충 배치는 다음 단계다.""" - pipes: list[StructureCandidate] = [] - for candidate in find_stream_crossings(vertices, stream_features): - if pipes and candidate.chainage_m - pipes[-1].chainage_m < DRAINAGE_PIPE_MIN_SPACING_M: - continue - pipes.append(candidate) - return pipes - - -# ── ③~④ 격자 해석 (캐시 대상) ─────────────────────────────────────────────── - - -def _solve_grid( - vertices: list[RouteVertex], - route_line: LineString, - contour_features: list[dict[str, Any]], - stream_features: list[dict[str, Any]], - cache_path: Path | None, -) -> _GridSolution | None: - signature = _signature(vertices, len(contour_features), len(stream_features)) - cached = _load_cache(cache_path, signature) - if cached is not None: - logger.info("배수유역: 격자 캐시 재사용 (%s)", cache_path) - return cached - - region = resolve_primary_region(vertices, route_line, contour_features, stream_features) - if region is None: - return None - spec = region.spec - # TIN용 등고선은 격자가 확장될 여지까지 한 번에 읽어 두고, 회차마다 범위 안쪽만 골라 쓴다. - reach = DRAINAGE_EXPAND_STEP_M * DRAINAGE_MAX_EXPAND_ROUNDS - x_min, y_min, x_max, y_max = region.area.bounds - cloud = build_contour_cloud( - contour_features, - route_elevation_floor([vertex.z for vertex in vertices]), - (x_min - reach, y_min - reach, x_max + reach, y_max + reach), - ) - if cloud.is_empty: - logger.warning("배수유역: 1차 영역 안에 등고선이 없어 격자 해석을 건너뜁니다.") - return None - - # 확장 루프는 `Watershed_Flow.expand_until_closed()`로 분리했다(2026-07-31 사용자 지시). - # 단계 검증 미리보기(`preview_stages`)는 이 경로를 타지 않는다 — 확장 자체가 아직 검증 대상. - started = time.perf_counter() - expansion = expand_until_closed(spec, cloud, route_line) - spec, terrain, road, flow = expansion.spec, expansion.terrain, expansion.road, expansion.flow - logger.info( - "배수유역: 격자 해석 %.1fs (확장 %d회, %s, 셀 %d개)", - time.perf_counter() - started, - expansion.rounds, - "닫힘" if expansion.closed else "미닫힘", - spec.size, - ) - solution = _GridSolution( - spec=spec, - elevation=terrain.elevation.reshape(-1), - road_cell_index=road.cell_index, - road_chainage=road.chainage, - road_slot=flow.road_slot, - path_length=flow.path_length, - strength=flow.strength, - active=flow.active.reshape(-1), - signature=signature, - ) - _save_cache(cache_path, solution) - return solution - - -def _main_boundary(solution: _GridSolution) -> list[tuple[float, float]]: - boundary = outer_boundary( - solution.spec, solution.active.reshape(solution.spec.n_rows, solution.spec.n_cols) - ) - return largest_ring(boundary) if boundary is not None else [] - - -# ── 흐름 강도 곡선 ────────────────────────────────────────────────────────── - - -def _strength_by_chainage( - road_chainage: np.ndarray, strength_area: np.ndarray, total_length: float -) -> np.ndarray: - """도로 셀 강도를 1m 누가거리 구간으로 합산한 곡선(㎡/m 구간 합).""" - bins = max(1, int(np.ceil(total_length)) + 1) - if road_chainage.size == 0: - return np.zeros(bins) - index = np.clip(np.round(road_chainage).astype(np.int64), 0, bins - 1) - return np.bincount(index, weights=strength_area, minlength=bins) - - -def _downsample_strength(curve: np.ndarray) -> list[tuple[float, float]]: - """응답용으로 강도 곡선을 일정 간격으로 줄인다(구간 합 유지). - - 끝자락을 잘라내면 종점 부근 유입 면적이 통째로 사라지므로 0으로 채워 맞춘다. - """ - step = max(1, int(_STRENGTH_OUTPUT_STEP_M)) - if curve.size == 0: - return [] - padding = (-curve.size) % step - padded = np.append(curve, np.zeros(padding)) if padding else curve - summed = padded.reshape(-1, step).sum(axis=1) - return [ - (float(position * step), float(value)) for position, value in enumerate(summed) if value > 0 - ] - - -# ── ⑤ 관 배치 ─────────────────────────────────────────────────────────────── - - -def _place_pipes( - vertices: list[RouteVertex], - stream_features: list[dict[str, Any]], - strength_curve: np.ndarray, -) -> list[StructureCandidate]: - """세류 교차점을 기본 관 위치로 두고, 최대 간격을 넘는 구간만 최소 개수로 보충한다. - - 교차점은 종단 절·성토를 가리지 않고 모두 관으로 둔다. 하류측 세류선은 이미 격자 - 해석 전에 제거되었으므로, 남은 교차점은 전부 상류에서 물이 실제로 들어오는 지점이다. - """ - total_length = vertices[-1].chainage_m - base: list[StructureCandidate] = [] - for candidate in find_stream_crossings(vertices, stream_features): - if base and candidate.chainage_m - base[-1].chainage_m < DRAINAGE_PIPE_MIN_SPACING_M: - continue - base.append(candidate) - - filled: list[StructureCandidate] = [] - previous = 0.0 - for candidate in [*base, None]: - boundary = candidate.chainage_m if candidate else total_length - filled.extend(_fill_gap(vertices, strength_curve, previous, boundary)) - if candidate: - filled.append(candidate) - previous = candidate.chainage_m - else: - previous = boundary - filled.sort(key=lambda item: item.chainage_m) - return filled - - -def _fill_gap( - vertices: list[RouteVertex], - strength_curve: np.ndarray, - start_m: float, - end_m: float, -) -> list[StructureCandidate]: - """[start, end] 구간에 최대 간격을 지키는 **최소 개수**의 관을 배치한다. - - 필요 개수 n은 구간 길이로 정해지고(ceil(L/max) − 1), 각 관은 등분 위치를 중심으로 - 허용 여유(slack) 안에서만 움직인다. 그래서 개수는 늘지 않으면서도 흐름 강도가 크고 - 종단이 낮은 지점으로 붙는다. - """ - span = end_m - start_m - if span <= DRAINAGE_PIPE_MAX_SPACING_M: - return [] - count = int(np.ceil(span / DRAINAGE_PIPE_MAX_SPACING_M)) - 1 - if count <= 0: - return [] - spacing = span / (count + 1) - slack = max(0.0, (DRAINAGE_PIPE_MAX_SPACING_M - spacing) / 2.0) - placed: list[StructureCandidate] = [] - for order in range(1, count + 1): - nominal = start_m + spacing * order - low = max(start_m + DRAINAGE_PIPE_MIN_SPACING_M, nominal - slack) - high = min(end_m - DRAINAGE_PIPE_MIN_SPACING_M, nominal + slack) - chosen = _best_position(vertices, strength_curve, low, high, nominal) - x, y, _ = _interpolate_vertex(vertices, chosen) - placed.append(StructureCandidate(chainage_m=chosen, x=x, y=y, reason="spacing")) - return placed - - -def _best_position( - vertices: list[RouteVertex], - strength_curve: np.ndarray, - low_m: float, - high_m: float, - fallback_m: float, -) -> float: - """허용 구간 안에서 흐름 강도가 크고 종단이 낮은 위치를 고른다.""" - if high_m <= low_m: - return fallback_m - positions = np.arange(low_m, high_m + 1.0, 1.0) - if positions.size == 0: - return fallback_m - index = np.clip(np.round(positions).astype(np.int64), 0, strength_curve.size - 1) - strength = strength_curve[index] - heights = np.array([_interpolate_vertex(vertices, float(p))[2] for p in positions]) - - strength_score = strength / strength.max() if strength.max() > 0 else np.zeros_like(strength) - height_span = float(heights.max() - heights.min()) - sag_score = ( - (heights.max() - heights) / height_span if height_span > 1e-6 else np.zeros_like(heights) - ) - score = _SCORE_WEIGHT_STRENGTH * strength_score + _SCORE_WEIGHT_SAG * sag_score - for order, position in enumerate(positions): - if not is_uphill_at(vertices, float(position)): - score[order] *= _SCORE_FILL_PENALTY - return float(positions[int(np.argmax(score))]) - - -def _pipes_from_chainages( - vertices: list[RouteVertex], chainages: list[float] -) -> list[StructureCandidate]: - """사용자가 확정·편집한 누가거리 목록을 관 후보로 되돌린다. - - 노선 밖 값은 시·종점으로 당긴다. 그대로 두면 마커는 끝점에 찍히는데 라벨만 −50m처럼 - 나와 좌표와 표기가 어긋난다. - """ - total_length = vertices[-1].chainage_m - clamped = {min(max(round(float(item), 2), 0.0), total_length) for item in chainages} - pipes: list[StructureCandidate] = [] - for value in sorted(clamped): - x, y, _ = _interpolate_vertex(vertices, value) - pipes.append(StructureCandidate(chainage_m=value, x=x, y=y, reason="confirmed")) - return pipes - - -# ── ⑥ 측구 흐름으로 도로 셀 → 담당 관 ─────────────────────────────────────── - - -def _assign_road_cells_to_pipes( - vertices: list[RouteVertex], - pipes: list[StructureCandidate], - road_chainage: np.ndarray, -) -> np.ndarray: - """도로 셀마다 물이 실제로 흘러가는 담당 관 번호를 정한다. - - 노면 물은 측구를 타고 종단 내리막으로 흐르므로, 종단 계획선을 1차원 지형으로 보고 - 같은 방식(내리막 추적 + 관에서 흡수)으로 푼다. 관이 없는 사그(저점)에 갇힌 구간은 - 가장 가까운 관이 받는 것으로 본다. - """ - total_length = vertices[-1].chainage_m - step = max(DRAINAGE_DITCH_SAMPLE_M, 0.5) - stations = np.arange(0.0, total_length + step, step) - heights = np.array([_interpolate_vertex(vertices, float(s))[2] for s in stations]) - pipe_chainages = np.array([pipe.chainage_m for pipe in pipes]) - pipe_station = np.clip(np.round(pipe_chainages / step).astype(np.int64), 0, stations.size - 1) - - # 앞뒤 이웃 중 더 낮은 쪽으로 흘려보낸다(양쪽 다 높으면 사그 = 제자리). - back_z = np.full(stations.size, np.inf) - back_z[1:] = heights[:-1] - forward_z = np.full(stations.size, np.inf) - forward_z[:-1] = heights[1:] - go_back = (back_z < heights) & (back_z <= forward_z) - go_forward = (forward_z < heights) & ~go_back - receiver = np.arange(stations.size, dtype=np.int64) - receiver[go_back] -= 1 - receiver[go_forward] += 1 - receiver[pipe_station] = pipe_station # 관은 물을 흡수한다 - - owner = np.full(stations.size, -1, dtype=np.int64) - owner[pipe_station] = np.arange(pipe_chainages.size) - jump = receiver - for _ in range(40): - next_jump = jump[jump] - if np.array_equal(next_jump, jump): - break - jump = next_jump - resolved = owner[jump] - # 관 없는 사그에 갇힌 구간은 가장 가까운 관에 붙인다. - orphan = resolved < 0 - if orphan.any() and pipe_chainages.size: - nearest = np.abs(stations[orphan, None] - pipe_chainages[None, :]).argmin(axis=1) - resolved[orphan] = nearest - - slot_station = np.clip(np.round(road_chainage / step).astype(np.int64), 0, stations.size - 1) - return resolved[slot_station].astype(np.int32) - - -# ── 세부유역 조립 ─────────────────────────────────────────────────────────── - - -def _assemble_basins( - solution: _GridSolution, - pipes: list[StructureCandidate], - pipe_of_slot: np.ndarray, -) -> list[WatershedBasin]: - """셀이 도달한 도로 셀의 담당 관을 그대로 유역 번호로 삼아 세부유역을 만든다.""" - spec = solution.spec - labels = np.full(spec.size, -1, dtype=np.int32) - reached = solution.road_slot >= 0 - labels[reached] = pipe_of_slot[solution.road_slot[reached]] - - polygons = polygonize_labels(spec, labels) - cell_area = spec.cell_area_m2 - basins: list[WatershedBasin] = [] - for order, pipe in enumerate(pipes): - member = labels == order - count = int(member.sum()) - if count == 0: - continue - geometry = polygons.get(order) - elevations = solution.elevation[member] - highest = float(np.nanmax(elevations)) if np.isfinite(elevations).any() else 0.0 - outlet_z = _outlet_elevation(solution, order, pipe_of_slot) - area = count * cell_area - relief = max(0.0, highest - outlet_z) - flow_length = float(solution.path_length[member].max()) - basins.append( - WatershedBasin( - index=len(basins) + 1, - chainage_m=pipe.chainage_m, - outlet_x=pipe.x, - outlet_y=pipe.y, - boundary_xy=largest_ring(geometry) if geometry is not None else [], - area_m2=area, - relief_m=relief, - flow_length_m=flow_length, - pipe_diameter_mm=estimate_pipe_diameter_mm(area, relief, flow_length), - ) - ) - return basins - - -def _outlet_elevation(solution: _GridSolution, pipe_order: int, pipe_of_slot: np.ndarray) -> float: - """관이 담당하는 도로 셀들의 최저 표고 = 유역 출구 표고.""" - slots = np.flatnonzero(pipe_of_slot == pipe_order) - if slots.size == 0: - return 0.0 - elevations = solution.elevation[solution.road_cell_index[slots]] - finite = elevations[np.isfinite(elevations)] - return float(finite.min()) if finite.size else 0.0 - - -# ── 캐시 ──────────────────────────────────────────────────────────────────── - - -def _signature(vertices: list[RouteVertex], contour_count: int, stream_count: int) -> str: - """노선 기하와 해석 파라미터가 바뀌면 캐시를 버리도록 하는 지문.""" - digest = hashlib.sha1() - for vertex in vertices: - digest.update(f"{vertex.x:.2f},{vertex.y:.2f},{vertex.z:.2f};".encode()) - digest.update( - f"|{contour_count}|{stream_count}|{DRAINAGE_GRID_SIZE_M}|{DRAINAGE_INITIAL_RADIUS_M}" - f"|{DRAINAGE_EXPAND_STEP_M}|{DRAINAGE_ROAD_WIDTH_M}".encode() - ) - return digest.hexdigest() - - -def _load_cache(cache_path: Path | None, signature: str) -> _GridSolution | None: - if cache_path is None or not cache_path.exists(): - return None - try: - with np.load(cache_path, allow_pickle=False) as data: - if str(data["signature"]) != signature: - return None - spec = GridSpec( - x_min=float(data["x_min"]), - y_max=float(data["y_max"]), - cell_m=float(data["cell_m"]), - n_rows=int(data["n_rows"]), - n_cols=int(data["n_cols"]), - ) - return _GridSolution( - spec=spec, - elevation=data["elevation"], - road_cell_index=data["road_cell_index"], - road_chainage=data["road_chainage"], - road_slot=data["road_slot"], - path_length=data["path_length"], - strength=data["strength"], - active=data["active"], - signature=signature, - ) - except (OSError, KeyError, ValueError): - logger.warning("배수유역: 격자 캐시를 읽지 못해 다시 계산합니다 (%s).", cache_path) - return None - - -def _save_cache(cache_path: Path | None, solution: _GridSolution) -> None: - if cache_path is None: - return - try: - cache_path.parent.mkdir(parents=True, exist_ok=True) - np.savez_compressed( - cache_path, - signature=solution.signature, - x_min=solution.spec.x_min, - y_max=solution.spec.y_max, - cell_m=solution.spec.cell_m, - n_rows=solution.spec.n_rows, - n_cols=solution.spec.n_cols, - elevation=solution.elevation, - road_cell_index=solution.road_cell_index, - road_chainage=solution.road_chainage, - road_slot=solution.road_slot, - path_length=solution.path_length, - strength=solution.strength, - active=solution.active, - ) - except OSError: - logger.warning("배수유역: 격자 캐시를 저장하지 못했습니다 (%s).", cache_path) diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py index 6c5334d4..7861567a 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py @@ -1,124 +1,36 @@ -"""배수유역도 API 라우터. +"""배수유역 세부 설계 API 라우터 (B05 — 일반 사용자용). + +**분석하지 않는다.** B04가 미리 돌려 저장한 결과를 읽어 관을 보충하고 세부유역만 나눈다. +격자 해석은 30초가 걸려 일반 사용자를 붙잡아 두므로 여기서는 아예 돌리지 않는다 +(2026-07-31 사용자 지시). -구조물 측점(관 매설) 후보 제안과 배수유역 산정을 제공한다. 지형 근거는 **도엽 등고선과 -세류선 GeoJSON**뿐이며(표고점은 유효 데이터가 적어 2026-07-31 사용자 지시로 제외), 좌표는 사업지 CRS(m)에서 계산하고 응답만 WGS84로 바꿔 내보낸다. """ import asyncio -import base64 -import json import logging -from pathlib import Path from typing import Any from uuid import UUID -import numpy as np from fastapi import APIRouter from fastapi.responses import JSONResponse from pyproj import Transformer -from shapely.geometry import LineString, Point, Polygon, box from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path -from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import ( - StructureCandidate, - build_route_vertices, - propose_structure_stations, -) -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Basin import ( - build_drainage_watershed, - preview_stages, -) -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Export import write_grid_arrays, write_stage -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import ( - AZIMUTH_INVALID, - AZIMUTH_SINK, - AZIMUTH_STEPS, - mask_row_spans, -) +from B05_wf2_Route.B05_wf2_Route_Engine_Drainage_Basin import build_drainage_detail from B05_wf2_Route.B05_wf2_Route_Repository import ( get_latest_route, get_route_points, get_surface_crs_epsg, ) -from common_util.common_util_storage import resolve_stored_project_path +from common_util.common_util_route_geometry import StructureCandidate, build_route_vertices from config.config_db import get_db_pool -from config.config_system import DRAINAGE_CACHE_DIRNAME, DRAINAGE_CACHE_FILENAME logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B05 Route Drainage"]) -# 도엽 레이어 파일명 (B04 전처리 산출물과 동일 위치) -_CONTOUR_FILE = "도엽_등고선.geojson" -_STREAM_FILE = "도엽_하천중심선.geojson" - -def _sheet_dir(stored_path: str) -> Path: - return Path(resolve_stored_project_path(stored_path)) / "B04_wf1_Surface" / "processed" - - -def _cache_path(stored_path: str) -> Path: - """격자 해석 캐시(.npz) 경로. 관을 옮겨도 격자를 다시 풀지 않게 여기에 남긴다.""" - root = Path(resolve_stored_project_path(stored_path)) / "B05_wf2_Route" - return root / DRAINAGE_CACHE_DIRNAME / DRAINAGE_CACHE_FILENAME - - -def _load_features(directory: Path, filename: str) -> list[dict[str, Any]]: - """도엽 GeoJSON을 읽어 피처 목록만 돌려준다. 없으면 빈 목록.""" - path = directory / filename - if not path.exists(): - return [] - try: - with path.open("r", encoding="utf-8") as file: - data = json.load(file) - except (OSError, json.JSONDecodeError): - logger.warning("도엽 GeoJSON을 읽지 못했습니다: %s", path) - return [] - features = data.get("features") - return features if isinstance(features, list) else [] - - -def _reproject_features( - features: list[dict[str, Any]], - transformer: Transformer | None, -) -> list[dict[str, Any]]: - """WGS84 도엽 좌표를 사업지 CRS(m)로 바꾼다. 거리·면적을 미터로 계산하기 위함.""" - if transformer is None: - return features - converted: list[dict[str, Any]] = [] - for feature in features: - geometry = feature.get("geometry") - if not geometry: - continue - coordinates = _map_coordinates(geometry.get("coordinates"), transformer) - if coordinates is None: - continue - converted.append( - { - "type": "Feature", - "properties": feature.get("properties") or {}, - "geometry": {"type": geometry.get("type"), "coordinates": coordinates}, - } - ) - return converted - - -def _map_coordinates(coordinates: Any, transformer: Transformer) -> Any: - """중첩 좌표 배열을 재귀적으로 변환한다.""" - if not isinstance(coordinates, list) or not coordinates: - return None - first = coordinates[0] - if isinstance(first, (int, float)): - x, y = transformer.transform(float(coordinates[0]), float(coordinates[1])) - return [x, y] - mapped = [_map_coordinates(item, transformer) for item in coordinates] - return [item for item in mapped if item is not None] - - -def _candidate_payload( - candidate: StructureCandidate, - to_lonlat: Any, -) -> dict[str, Any]: +def _candidate_payload(candidate: StructureCandidate, to_lonlat: Any) -> dict[str, Any]: lon, lat = to_lonlat(candidate.x, candidate.y) return { "chainage_m": round(candidate.chainage_m, 2), @@ -132,7 +44,7 @@ def _candidate_payload( async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse: - """노선 정점·도엽 피처·좌표 변환기를 한 번에 준비한다.""" + """확정 노선과 좌표 변환기를 준비한다. 도엽 피처는 읽지 않는다(분석을 안 하므로).""" pool = get_db_pool() async with pool.acquire() as connection: stored_path = await get_project_storage_relative_path(connection, project_id) @@ -154,346 +66,67 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse: status_code=400, content={"status": "error", "message": "노선 좌표가 부족합니다."}, ) - - source_crs = f"EPSG:{epsg}" if epsg else "EPSG: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) - - directory = _sheet_dir(stored_path) - streams = _reproject_features(_load_features(directory, _STREAM_FILE), to_metric_transformer) - contour_features = _reproject_features( - _load_features(directory, _CONTOUR_FILE), to_metric_transformer - ) + transformer = Transformer.from_crs(f"EPSG:{epsg or 5186}", "EPSG:4326", always_xy=True) return { "route_id": int(route["id"]), "vertices": vertices, - "route_line": LineString([(vertex.x, vertex.y) for vertex in vertices]), - "streams": streams, - "contours": contour_features, "stored_path": stored_path, - "cache_path": _cache_path(stored_path), - "to_lonlat": lambda x, y: to_lonlat_transformer.transform(x, y), + "to_lonlat": lambda x, y: transformer.transform(x, y), } -@router.get("/{project_id}/drainage/candidates", response_model=None) -async def get_structure_candidates(project_id: UUID) -> dict[str, Any] | JSONResponse: - """관 매설 구조물 측점 후보를 제안한다(세류 교차 + 300m 보충, 성토부 제외).""" - prepared = await _prepare(project_id) - if isinstance(prepared, JSONResponse): - return prepared - candidates = propose_structure_stations(prepared["vertices"], prepared["streams"]) - to_lonlat = prepared["to_lonlat"] - return { - "status": "success", - "project_id": str(project_id), - "route_id": prepared["route_id"], - "candidates": [_candidate_payload(candidate, to_lonlat) for candidate in candidates], - } - - -@router.get("/{project_id}/drainage/primary-region", response_model=None) -async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse: - """1차 배수유역 근거를 돌려준다 — 단계 검증용, TIN·흐름 계산은 하지 않는다. - - 도로 교차점 상류로 이어진 세류망, 제외된 하류망, 그 상류망을 반경 버퍼한 1차 영역, - 그 bbox로 잡은 격자 정보를 함께 준다. 같은 내용을 영구저장소에 GeoJSON으로도 남겨 - QGIS 등으로 직접 열어 대조할 수 있게 한다. - """ - prepared = await _prepare(project_id) - if isinstance(prepared, JSONResponse): - return prepared - preview = await asyncio.to_thread( - preview_stages, - prepared["vertices"], - prepared["contours"], - prepared["streams"], - ) - if preview is None: - return JSONResponse( - status_code=400, - content={"status": "error", "message": "1차 배수유역을 정할 등고선이 없습니다."}, - ) - region = preview.region - to_lonlat = prepared["to_lonlat"] - # 확장을 거치면 격자·해석 영역이 1차 영역보다 커진다. 최종본을 써야 화면과 어긋나지 않는다. - spec = preview.spec or region.spec - domain = preview.domain if preview.domain is not None else region.cell_mask - payload = { - "status": "success", - "project_id": str(project_id), - "route_id": prepared["route_id"], - "radius_m": region.radius_m, - # 채택된 상류 세류망 = 1차 영역의 기준선. - "upstream_lines": [_line_lonlat(line, to_lonlat) for line in region.split.upstream], - # 도로 아래로 이어진 하류망 — 판정이 맞는지 눈으로 대조하기 위해 함께 준다. - "downstream_lines": [_line_lonlat(line, to_lonlat) for line in region.split.downstream], - "no_contact_count": region.split.no_contact, - # 노선이 1차 영역 밖으로 나간 길이(m). 크면 반경을 올려야 한다는 신호. - "road_outside_m": round(region.road_outside_m, 1), - # 1차 영역(버퍼 합집합) 외곽 링 목록. - "region_rings": _polygon_rings(region.area, to_lonlat), - "grid": { - "cell_m": spec.cell_m, - "rows": spec.n_rows, - "cols": spec.n_cols, - # bbox 전체 셀 수와, 해석 영역에 실제로 생성된 셀 수(확장 반영). - "bbox_cells": spec.size, - "cells": int(domain.sum()) if domain is not None else 0, - "width_m": round(spec.n_cols * spec.cell_m, 1), - "height_m": round(spec.n_rows * spec.cell_m, 1), - # 격자 bbox 링. 프론트는 이 사각형을 rows×cols로 나눠 행·열 좌표를 얻는다. - "bbox_lonlat": _grid_bbox_lonlat(spec, to_lonlat), - # 실제 생성된 셀을 행별 연속 구간 [행, 시작열, 끝열]으로 압축해 보낸다. - # 셀을 낱개로 보내면 수십만 건이라 응답이 감당되지 않는다. - "row_spans": [list(span) for span in mask_row_spans(domain)] - if domain is not None - else [], - }, - # 최외곽 적색 셀 주변 확장 결과. - "expansion": { - "rounds": preview.expand_rounds, - "closed": preview.expand_closed, - "added_cells": preview.expand_added_cells, - "initial_cells": region.active_cells, - }, - # 셀별 흐름 방향과 도로 도달 여부. row_spans 순서(행 → 구간 → 열 오름차순)로 1바이트씩. - "flow": _flow_payload(preview, domain), - # ⑦ 2차 전체 배수유역 외곽선(= 분수령)과 면적. - "basin_polygon_lonlat": [list(to_lonlat(x, y)) for x, y in preview.basin_boundary_xy], - "basin_area_m2": round(preview.basin_area_m2, 1), - # ⑥ 도로 위 흐름 강도 — [누가거리 m, 그 구간으로 모이는 상류 면적 ㎡]. - "strength_profile": [ - [round(chainage, 1), round(area, 1)] for chainage, area in preview.strength_profile - ], - # ⑧ 기본 관 매설 위치 — 도로 × 세류선 교차점. - "pipes": [_candidate_payload(pipe, to_lonlat) for pipe in preview.pipes], - } - # 단계 산출물을 영구저장소에 남긴다 — 기능을 붙일 때마다 여기에 단계가 하나씩 늘어난다. - payload["saved_to"] = write_stage( - prepared["stored_path"], - "primary_region", - { - "primary_region": _as_polygons(region.area), - "upstream": region.split.upstream, - "downstream": region.split.downstream, - "route": [prepared["route_line"]], - "grid_bbox": [_grid_bbox_polygon(spec)], - # ⑦ 2차 전체 배수유역 외곽선, ⑧ 기본 관 위치(누가거리·근거 포함). - "basin_boundary": _boundary_geometry(preview.basin_boundary_xy), - "pipe": [ - ( - Point(pipe.x, pipe.y), - { - "chainage_m": round(pipe.chainage_m, 2), - "reason": pipe.reason, - "stream_name": pipe.stream_name, - }, - ) - for pipe in preview.pipes - ], - }, - { - "radius_m": region.radius_m, - "road_outside_m": payload["road_outside_m"], - "no_contact_count": region.split.no_contact, - "basin_area_m2": payload["basin_area_m2"], - "pipe_count": len(preview.pipes), - # 기하(bbox·셀 구간)는 파일 본문에 있으므로 요약 수치만 남긴다. - "grid": { - key: value - for key, value in payload["grid"].items() - if key not in {"bbox_lonlat", "row_spans"} - }, - }, - to_lonlat, - ) - _write_stage_arrays(prepared["stored_path"], preview, domain, spec) - return payload - - -def _write_stage_arrays(stored_path: str, preview: Any, domain: Any, spec: Any) -> None: - """격자 규모 배열(셀 마스크·흐름 방향·도달 여부)을 단계별 `.npz`로 남긴다.""" - if domain is not None: - write_grid_arrays( - stored_path, - "primary_region", - spec, - {"mask": domain}, - { - "cells": int(domain.sum()), - "bbox_cells": spec.size, - "expand_rounds": preview.expand_rounds, - "expand_closed": preview.expand_closed, - }, - ) - flow = preview.flow - if flow is None: - return - arrays = { - "direction": flow.direction.reshape(spec.n_rows, spec.n_cols), - "reaches_road": flow.reaches_road.reshape(spec.n_rows, spec.n_cols), - "analyzed": flow.analyzed.reshape(spec.n_rows, spec.n_cols), - # 사후 진단용 — 수신 셀이 있어야 사슬을 다시 따라가 볼 수 있다. - "receiver": preview.terrain.receiver.reshape(spec.n_rows, spec.n_cols), - } - if flow.burned is not None: - arrays["burned"] = flow.burned.reshape(spec.n_rows, spec.n_cols) - if preview.descent is not None: - arrays["band_elevation"] = preview.descent.band_elevation - # ⑥ 흐름 강도 곡선 — 기하가 아니라 수치 곡선이라 GeoJSON이 아닌 여기에 함께 담는다. - if preview.strength_profile: - curve = np.asarray(preview.strength_profile, dtype=np.float64) - arrays["strength_chainage_m"] = curve[:, 0] - arrays["strength_area_m2"] = curve[:, 1] - write_grid_arrays( - stored_path, - "flow_direction", - spec, - arrays, - { - "azimuth_steps": AZIMUTH_STEPS, - "sink_code": AZIMUTH_SINK, - "invalid_code": AZIMUTH_INVALID, - "analyzed": int(flow.analyzed.sum()), - "reaches_road": int((flow.reaches_road & flow.analyzed).sum()), - "no_road": int((~flow.reaches_road & flow.analyzed).sum()), - "burned": 0 if flow.burned is None else int(flow.burned.sum()), - "outer_seeds": flow.outer_seeds, - "interior_seeds": flow.interior_seeds, - "strength_points": len(preview.strength_profile), - "strength_total_m2": round(sum(area for _, area in preview.strength_profile), 1), - }, - ) - - -def _flow_payload(preview: Any, domain: Any) -> dict[str, Any] | None: - """셀별 흐름 방향·도로 도달 여부를 바이트 배열로 압축한다. - - 셀이 수십만 개라 JSON 객체로는 못 보낸다. 셀 하나당 1바이트로 줄이고 base64로 싣는다: - 하위 6비트(0x3F) = 32방위 코드(0~31, 0=화면 오른쪽·시계방향), 32=제자리, 33=표고 없음 - 최상위 비트(0x80) = 도로 도달(적색). 꺼져 있으면 미도달(백색 화살표). - 바이트 순서는 `grid.row_spans`를 행 → 구간 → 열 오름차순으로 훑은 순서와 같다. - """ - flow = preview.flow - if flow is None or domain is None: - return None - order = np.flatnonzero(domain.reshape(-1)) - analyzed = flow.analyzed[order] - reaches = flow.reaches_road[order] - packed = np.clip(flow.direction[order], 0, AZIMUTH_INVALID).astype(np.uint8) - packed |= np.where(reaches, 0x80, 0).astype(np.uint8) - burned = flow.burned - return { - "encoding": "base64-uint8", - "azimuth_steps": AZIMUTH_STEPS, - "sink_code": AZIMUTH_SINK, - "invalid_code": AZIMUTH_INVALID, - "cells": int(order.size), - "reaches_road": int((reaches & analyzed).sum()), - "no_road": int((~reaches & analyzed).sum()), - # 격자에는 있으나 등고선 TIN 밖이라 표고가 없어 판정 못한 셀. - "unanalyzed": int((~analyzed).sum()), - # 확정된 상류 세류망을 따라 흐름을 강제로 새긴 셀 수. - "burned": 0 if burned is None else int(burned[order].sum()), - "outer_seeds": flow.outer_seeds, - "interior_seeds": flow.interior_seeds, - "data": base64.b64encode(packed.tobytes()).decode("ascii"), - } - - -def _boundary_geometry(ring: list[tuple[float, float]]) -> list[Any]: - """2차 유역 외곽 링을 저장용 폴리곤으로 만든다(정점 3개 미만이면 비운다).""" - return [Polygon(ring)] if len(ring) >= 4 else [] - - -def _as_polygons(geometry: Any) -> list[Any]: - if geometry is None or geometry.is_empty: - return [] - return list(geometry.geoms) if geometry.geom_type == "MultiPolygon" else [geometry] - - -def _grid_bbox_polygon(spec: Any) -> Polygon: - x_max = spec.x_min + spec.n_cols * spec.cell_m - y_min = spec.y_max - spec.n_rows * spec.cell_m - return box(spec.x_min, y_min, x_max, spec.y_max) - - -def _line_lonlat(line: Any, to_lonlat: Any) -> list[list[float]]: - return [list(to_lonlat(x, y)) for x, y in line.coords] - - -def _polygon_rings(geometry: Any, to_lonlat: Any) -> list[list[list[float]]]: - """폴리곤/멀티폴리곤의 외곽 링만 뽑아 lonlat으로 바꾼다.""" - if geometry is None or geometry.is_empty: - return [] - parts = geometry.geoms if geometry.geom_type == "MultiPolygon" else [geometry] - return [[list(to_lonlat(x, y)) for x, y in part.exterior.coords] for part in parts] - - -def _grid_bbox_lonlat(spec: Any, to_lonlat: Any) -> list[list[float]]: - x_min = spec.x_min - x_max = spec.x_min + spec.n_cols * spec.cell_m - y_max = spec.y_max - y_min = spec.y_max - spec.n_rows * spec.cell_m - corners = ((x_min, y_min), (x_min, y_max), (x_max, y_max), (x_max, y_min), (x_min, y_min)) - return [list(to_lonlat(x, y)) for x, y in corners] - - @router.post("/{project_id}/drainage/basins", response_model=None) async def post_drainage_basins( project_id: UUID, payload: dict[str, Any] | None = None, ) -> dict[str, Any] | JSONResponse: - """격자 흐름 해석으로 배수유역과 관 배치를 산정한다. + """B04 분석 결과로 관을 보충하고 세부유역을 나눈다. - payload에 `chainages`(누가거리 목록)를 주면 그 위치로 관을 확정하고, 없으면 세류 - 교차 + 최소 보충으로 자동 배치한다. 격자 해석은 `.npz` 캐시를 재사용하므로 관만 - 옮기는 재요청은 세부유역 분할만 다시 돈다. + payload에 `chainages`(누가거리 목록)를 주면 그 위치로 관을 확정하고(사용자 편집), + 없으면 B04의 기본 관에 최대 간격 규칙으로 최소 개수만 보충한다. """ prepared = await _prepare(project_id) if isinstance(prepared, JSONResponse): return prepared - raw_chainages = (payload or {}).get("chainages") - confirmed = _parse_chainages(raw_chainages) if isinstance(raw_chainages, list) else [] + raw = (payload or {}).get("chainages") + confirmed = _parse_chainages(raw) if isinstance(raw, list) else [] - # 격자 해석은 수백만 셀 numpy 연산이라 이벤트 루프를 막지 않도록 스레드로 뺀다. - result = await asyncio.to_thread( - build_drainage_watershed, - prepared["vertices"], - prepared["contours"], - prepared["streams"], - confirmed, - prepared["cache_path"], + detail = await asyncio.to_thread( + build_drainage_detail, prepared["stored_path"], prepared["vertices"], confirmed ) + if detail is None: + return JSONResponse( + status_code=404, + content={ + "status": "error", + "message": "배수유역 분석 결과가 없습니다. B04에서 먼저 분석을 실행하세요.", + }, + ) + to_lonlat = prepared["to_lonlat"] return { "status": "success", "project_id": str(project_id), "route_id": prepared["route_id"], - # 계획선 위 배관(관 매설) 지점 — 유역이 없는 관도 마커로 표시해야 하므로 별도 목록. - "pipes": [_candidate_payload(candidate, to_lonlat) for candidate in result.pipes], - # 2차 전체 배수유역 외곽선 = 분수령. 세부유역은 전부 이 안쪽에 들어간다. - "main_polygon_lonlat": [list(to_lonlat(x, y)) for x, y in result.main_boundary_xy], - # 도로 위 흐름 강도 — [누가거리 m, 그 지점으로 모이는 상류 면적 ㎡]. - "strength_profile": [ - [round(chainage, 1), round(area, 1)] for chainage, area in result.strength_profile - ], - "grid_cell_m": result.grid_cell_m, + # B04가 남긴 그대로 — 계획도로선과 2차 전체 배수유역 외곽선. + "route_lonlat": detail.route_lonlat, + "main_polygon_lonlat": detail.basin_lonlat, + "grid_cell_m": detail.grid_cell_m, + # 계획선 위 배관 지점 — 유역이 없는 관도 마커로 표시해야 하므로 별도 목록. + "pipes": [_candidate_payload(pipe, to_lonlat) for pipe in detail.pipes], "basins": [ { "index": basin.index, "chainage_m": round(basin.chainage_m, 2), - # 관(배관) 매설 지점 좌표 — 계획선 위 마커 렌더용. "outlet_lonlat": list(to_lonlat(basin.outlet_x, basin.outlet_y)), "polygon_lonlat": [list(to_lonlat(x, y)) for x, y in basin.boundary_xy], "area_m2": round(basin.area_m2, 1), "relief_m": round(basin.relief_m, 2), "flow_length_m": round(basin.flow_length_m, 1), - # 관경 수식 미확정 — 산정 함수가 None을 돌려주면 프론트가 "미정"으로 표기한다. + # 관경 수식 미확정 — None이면 프론트가 "미정"으로 표기한다. "pipe_diameter_mm": basin.pipe_diameter_mm, } - for basin in result.basins + for basin in detail.basins ], } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index a26e9c40..1527252a 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -21,9 +21,7 @@ import { } from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender"; import { fetchDrainageBasins, - fetchDrainagePrimaryRegion, type DrainageBasin, - type DrainagePrimaryRegion, type RoutePoint, } from "./B05_wf2_Route_Api_Fetch"; import { createPipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes"; @@ -51,18 +49,6 @@ const LAYER_LABELS: Record = { const ROUTE_COLOR = "#f97316"; const COLLAPSED_KEY = "b05-route-drainage-collapsed"; -// 해석 격자 셀 선 — 등고선·세류 위에 얹으므로 흰색으로 둔다(2026-07-31 사용자 지시). -const GRID_LINE_COLOR = "rgba(255, 255, 255, 0.55)"; -// 흐름 판정 색 — 도로로 물이 오는 셀은 적색, 오지 않는 셀은 파랑 채움 + 백색 화살표. -const FLOW_TO_ROAD_FILL = "rgba(220, 38, 38, 0.28)"; -const FLOW_TO_ROAD_LINE = "rgba(153, 27, 27, 0.95)"; -const FLOW_AWAY_FILL = "rgba(37, 99, 235, 0.22)"; -const FLOW_AWAY_LINE = "rgba(255, 255, 255, 0.95)"; -/** 등고선 TIN 밖이라 표고가 없어 판정하지 못한 셀 — 미도달(파랑)과 구분한다. */ -const FLOW_UNKNOWN_FILL = "rgba(120, 113, 108, 0.18)"; -/** 셀이 이보다 작으면 화살표가 뭉개져 읽히지 않으므로 채움색만 남긴다(px). */ -const ARROW_MIN_PX = 7; - /** 유역 오버레이 파스텔 색상. 번호 순으로 돌려쓴다(사용자 지시: 파스텔톤). */ const BASIN_COLORS = [ "rgba(167, 216, 199, 0.45)", @@ -97,13 +83,15 @@ export function createDrainagePanel(): DrainagePanel { layerButtons.className = "b05-drainage__layers"; header.append(title, layerButtons); - // 배수유역 계산 실행 — 등고선 격자 흐름 해석으로 유역·관 위치를 한 번에 산정한다. - // (격자 해석 결과는 백엔드가 캐시하므로 관만 바꾼 재계산은 즉시 끝난다.) + // 세부유역 산정 — B04가 미리 분석해 둔 결과를 읽어 관을 보충하고 세부유역만 나눈다. + // 격자 해석은 하지 않으므로 즉시 끝난다. const analyzeButton = document.createElement("button"); analyzeButton.type = "button"; analyzeButton.className = "b05-drainage__analyze"; - analyzeButton.textContent = "배수유역 계산"; - analyzeButton.title = "등고선·세류선으로 배수유역과 관 매설 위치를 다시 계산합니다."; + analyzeButton.textContent = "세부유역 산정"; + analyzeButton.title = + "B04에서 분석해 둔 배수유역을 불러와 관을 보충하고 세부유역을 나눕니다. " + + "분석 결과가 없으면 B04에서 먼저 실행해야 합니다."; // 배관 편집 토글 — 켜면 계획선 클릭으로 배관 추가, 마커 드래그로 이동. const editButton = document.createElement("button"); editButton.type = "button"; @@ -121,16 +109,7 @@ export function createDrainagePanel(): DrainagePanel { autoButton.type = "button"; autoButton.className = "b05-drainage__analyze b05-drainage__tool"; autoButton.textContent = "자동 제안"; - // 1차 영역 확인 — TIN·흐름 계산 없이 세류 상·하류 판정과 격자 범위만 그려 눈으로 검증한다. - const regionButton = document.createElement("button"); - regionButton.type = "button"; - regionButton.className = "b05-drainage__analyze b05-drainage__tool"; - regionButton.textContent = "1차 영역"; - regionButton.title = - "도로와 만나는 세류선의 상류측(파랑 굵은 선)·하류측(회색 파선)과 " + - "그 반경 버퍼로 잡은 1차 배수유역, 해석 격자 범위를 표시합니다."; - regionButton.setAttribute("aria-pressed", "false"); - header.append(analyzeButton, editButton, deleteButton, autoButton, regionButton); + header.append(analyzeButton, editButton, deleteButton, autoButton); const viewport = document.createElement("div"); viewport.className = "b05-drainage__viewport"; @@ -168,11 +147,6 @@ export function createDrainagePanel(): DrainagePanel { // 2차 전체 배수유역 외곽선 = 분수령. 별도 "능선" 레이어를 두지 않고 이 선 하나로 표시한다 // (전체 유역 외곽선과 능선이 같은 선이므로 — 2026-07-31 사용자 지시). let mainBoundary: Array<[number, number]> = []; - // 1차 영역 검증 오버레이. null이면 표시하지 않는다. - let primaryRegion: DrainagePrimaryRegion | null = null; - let showRegion = false; - // 흐름 방향 바이트 디코드 캐시 — 매 프레임 base64를 다시 풀지 않는다. - let flowCache: { source: string; bytes: Uint8Array } | null = null; let scale = 1; let offsetX = 0; let offsetY = 0; @@ -241,8 +215,6 @@ export function createDrainagePanel(): DrainagePanel { }); // 전체 유역 외곽선 = 분수령(능선). 세부유역 경계와 구분되게 파선 한 겹만 얹는다. if (mainBoundary.length > 2) drawRidgeRing(context, mainBoundary, normalizer, view); - // 1차 영역 검증 오버레이는 채움 위·등고선 아래에 깐다. - if (showRegion && primaryRegion) drawPrimaryRegion(context, normalizer, view); } // 등고선을 얇게 깔고 세류를 그 위에, 노선을 맨 위에 둔다. DRAINAGE_LAYERS.forEach((layer) => { @@ -258,219 +230,12 @@ export function createDrainagePanel(): DrainagePanel { context.strokeStyle = ROUTE_COLOR; drawPreparedLayer(context, routeLayer, view, "dot"); } - // 계획선 위 흐름 강도 띠 → 그 위에 배관 마커. - pipeEditor.drawStrength(context, view); + // 배관(관 매설) 마커 — 계획선 위 최상단. pipeEditor.draw(context, view, pipeColor); updateImageTransform(); } - /** lon/lat 폴리라인을 화면 좌표로 옮겨 한 줄 그린다(1차 영역 오버레이 전용). */ - function strokeLonLat( - context: CanvasRenderingContext2D, - line: ReadonlyArray, - map: Normalizer, - view: ViewState, - ): void { - if (line.length < 2) return; - const ax = view.mapRect.width * view.scale; - const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; - const ay = view.mapRect.height * view.scale; - const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; - context.beginPath(); - line.forEach(([lon, lat], index) => { - const x = ((lon - map.lonMin) / map.lonRange) * ax + bx; - const y = (1 - (lat - map.latMin) / map.latRange) * ay + by; - if (index === 0) context.moveTo(x, y); - else context.lineTo(x, y); - }); - context.stroke(); - } - - /** 흐름 방향 바이트를 셀 순서대로 디코드한다(캐시 — 매 프레임 다시 풀지 않는다). */ - function flowBytes(region: DrainagePrimaryRegion): Uint8Array | null { - if (!region.flow) return null; - if (flowCache?.source === region.flow.data) return flowCache.bytes; - const binary = atob(region.flow.data); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); - flowCache = { source: region.flow.data, bytes }; - return bytes; - } - - /** 1차 영역에 걸쳐 실제로 생성된 셀만 그린다. - * - * bbox 전체를 채우지 않는다 — 백엔드가 준 행별 구간(row_spans)만 그린다. 흐름 판정이 - * 있으면 셀마다 방향 화살표를 얹고, 도로에 물이 닿는 셀은 적색·닿지 않으면 파랑으로 - * 칠한다. 셀이 화면에서 작아지면 화살표가 안 보이므로 채움색만 남긴다. */ - function drawGridCells( - context: CanvasRenderingContext2D, - map: Normalizer, - view: ViewState, - region: DrainagePrimaryRegion, - ): void { - const ring = region.grid.bbox_lonlat; - if (ring.length < 4) return; - const lons = ring.map(([lon]) => lon); - const lats = ring.map(([, lat]) => lat); - const ax = view.mapRect.width * view.scale; - const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; - const ay = view.mapRect.height * view.scale; - const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; - const left = ((Math.min(...lons) - map.lonMin) / map.lonRange) * ax + bx; - const right = ((Math.max(...lons) - map.lonMin) / map.lonRange) * ax + bx; - const top = (1 - (Math.max(...lats) - map.latMin) / map.latRange) * ay + by; - const bottom = (1 - (Math.min(...lats) - map.latMin) / map.latRange) * ay + by; - - const { rows, cols, row_spans: spans } = region.grid; - const cellW = (right - left) / Math.max(cols, 1); - const cellH = (bottom - top) / Math.max(rows, 1); - const cellPx = Math.min(Math.abs(cellW), Math.abs(cellH)); - const bytes = flowBytes(region); - - context.save(); - context.setLineDash([]); - context.lineCap = "round"; - let cursor = 0; // row_spans를 훑은 순서 = 흐름 바이트 순서 - spans.forEach(([row, colStart, colEnd]) => { - const count = colEnd - colStart + 1; - const base = cursor; - cursor += count; - const y = top + cellH * row; - if (y + cellH < -40 || y > view.height + 40) return; - const x = left + cellW * colStart; - const width = cellW * count; - if (x + width < -40 || x > view.width + 40) return; - - if (!bytes) { - // 흐름 판정 전 — 격자만 흰 선으로 보여 준다. - if (cellPx >= 2) { - context.strokeStyle = GRID_LINE_COLOR; - context.lineWidth = 0.5; - context.beginPath(); - for (let col = colStart; col <= colEnd; col += 1) { - context.rect(left + cellW * col, y, cellW, cellH); - } - context.stroke(); - } else { - context.fillStyle = "rgba(255, 255, 255, 0.2)"; - context.fillRect(x, y, width, cellH); - } - return; - } - const sink = region.flow?.sink_code ?? 32; - const invalid = region.flow?.invalid_code ?? 33; - const steps = region.flow?.azimuth_steps ?? 32; - for (let offset = 0; offset < count; offset += 1) { - drawFlowCell( - context, - bytes[base + offset], - left + cellW * (colStart + offset), - y, - cellW, - cellH, - cellPx, - { sink, invalid, steps }, - ); - } - }); - context.restore(); - } - - /** 셀 하나 — 도달 여부로 칠하고, 충분히 크면 32방위 흐름 화살표를 얹는다. */ - function drawFlowCell( - context: CanvasRenderingContext2D, - code: number, - x: number, - y: number, - cellW: number, - cellH: number, - cellPx: number, - codes: { sink: number; invalid: number; steps: number }, - ): void { - const azimuth = code & 0x3f; - const reaches = (code & 0x80) !== 0; - // 표고가 없어 판정 못한 셀 — 미도달(파랑 채움)과 구분해야 오독이 없다. - const unanalyzed = azimuth === codes.invalid; - context.fillStyle = unanalyzed - ? FLOW_UNKNOWN_FILL - : reaches - ? FLOW_TO_ROAD_FILL - : FLOW_AWAY_FILL; - context.fillRect(x, y, cellW, cellH); - if (cellPx >= 2) { - context.strokeStyle = GRID_LINE_COLOR; - context.lineWidth = 0.5; - context.strokeRect(x, y, cellW, cellH); - } - if (cellPx < ARROW_MIN_PX || unanalyzed) return; - const stroke = reaches ? FLOW_TO_ROAD_LINE : FLOW_AWAY_LINE; - const midX = x + cellW / 2; - const midY = y + cellH / 2; - if (azimuth === codes.sink) { - // 제자리(싱크) — 방향이 없으므로 점으로 표시한다. - context.fillStyle = stroke; - context.beginPath(); - context.arc(midX, midY, Math.max(1, cellPx * 0.12), 0, Math.PI * 2); - context.fill(); - return; - } - // 코드 0 = 화면 오른쪽(+x), 시계방향(캔버스 y는 아래가 +). - const angle = (azimuth * 2 * Math.PI) / codes.steps; - const unitX = Math.cos(angle); - const unitY = Math.sin(angle); - const reach = cellPx * 0.38; - const tipX = midX + unitX * reach; - const tipY = midY + unitY * reach; - context.strokeStyle = stroke; - context.lineWidth = Math.max(0.6, cellPx * 0.09); - context.beginPath(); - context.moveTo(midX - unitX * reach, midY - unitY * reach); - context.lineTo(tipX, tipY); - context.stroke(); - // 촉 — 진행 방향 기준 좌우로 짧게 접는다. - const head = cellPx * 0.18; - context.beginPath(); - context.moveTo(tipX, tipY); - context.lineTo(tipX - (unitX + unitY * 0.7) * head, tipY - (unitY - unitX * 0.7) * head); - context.moveTo(tipX, tipY); - context.lineTo(tipX - (unitX - unitY * 0.7) * head, tipY - (unitY + unitX * 0.7) * head); - context.stroke(); - } - - /** 1차 배수유역 근거를 겹쳐 그린다 — 단계 검증용. */ - function drawPrimaryRegion( - context: CanvasRenderingContext2D, - map: Normalizer, - view: ViewState, - ): void { - const region = primaryRegion; - if (!region) return; - context.save(); - // ① 해석 격자 — bbox 테두리 + 실제 셀 눈금. - drawGridCells(context, map, view, region); - // ② 1차 배수유역 = 상류 세류망의 반경 버퍼 합집합. - context.setLineDash([]); - context.lineWidth = 2; - context.strokeStyle = "rgba(5, 150, 105, 0.95)"; - context.fillStyle = "rgba(16, 185, 129, 0.12)"; - region.region_rings.forEach((ring) => { - strokeLonLat(context, ring, map, view); - context.fill(); - }); - // ③ 도로 아래로 이어진 하류망 — 판정이 맞는지 대조하도록 회색 파선으로 남긴다. - context.setLineDash([6, 5]); - context.lineWidth = 2; - context.strokeStyle = "rgba(120, 113, 108, 0.85)"; - region.downstream_lines.forEach((line) => strokeLonLat(context, line, map, view)); - // ④ 채택된 상류망 = 1차 영역의 기준선. 가장 굵게, 맨 위에. - context.setLineDash([]); - context.lineWidth = 4; - context.strokeStyle = "rgba(29, 78, 216, 0.95)"; - region.upstream_lines.forEach((line) => strokeLonLat(context, line, map, view)); - context.restore(); - } - - /** 현재 프레임 뷰 상태 (draw()와 동일 계산 — 포인터 히트 판정용). */ + /** 현재 프레임 뷰 상태 (draw()와 동일 계산 — 배관 마커 포인터 히트 판정용). */ function currentView(): ViewState { const rect = viewport.getBoundingClientRect(); const width = Math.max(1, Math.floor(rect.width)); @@ -552,6 +317,7 @@ export function createDrainagePanel(): DrainagePanel { const response = await fetchDrainageBasins(projectId, chainages); basins = response.basins; mainBoundary = response.main_polygon_lonlat ?? []; + // 계획도로선·2차 유역 외곽선은 B04 산출물을 그대로 받는다 — 여기서 다시 계산하지 않는다. // 실제 사용된 배관 목록으로 마커를 동기화한다(유역 없는 관 포함). pipeEditor.setPipes( (response.pipes ?? []).map((pipe) => ({ @@ -559,7 +325,6 @@ export function createDrainagePanel(): DrainagePanel { reason: pipe.reason, })), ); - pipeEditor.setStrength(response.strength_profile ?? []); selectedBasin = null; renderBasinList(); syncPipeSelection(); @@ -587,87 +352,6 @@ export function createDrainagePanel(): DrainagePanel { void analyze(true); }); - /** 1차 영역 근거를 불러와 겹쳐 그린다. - * - * 켤 때는 **항상 다시 요청한다** — config(반경·격자)를 바꾸고 서버를 재시작한 뒤 - * 눌렀는데 캐시된 예전 결과가 나오면 검증이 성립하지 않는다. 끌 때만 요청 없이 숨긴다. */ - async function toggleRegion(): Promise { - if (!projectId) return; - if (showRegion) { - showRegion = false; - regionButton.classList.remove("is-active"); - regionButton.setAttribute("aria-pressed", "false"); - status.hidden = true; - // 이 버튼이 얹은 2차 유역선·강도 띠·관 마커도 함께 걷는다. - mainBoundary = []; - pipeEditor.setPipes([]); - pipeEditor.setStrength([]); - scheduleDraw(); - return; - } - regionButton.disabled = true; - status.hidden = false; - status.textContent = "1차 배수유역을 확인하는 중…"; - try { - primaryRegion = await fetchDrainagePrimaryRegion(projectId); - // 2차 유역 외곽선·흐름 강도·기본 관 위치를 기존 렌더 경로에 그대로 태운다. - mainBoundary = primaryRegion.basin_polygon_lonlat ?? []; - pipeEditor.setPipes( - (primaryRegion.pipes ?? []).map((pipe) => ({ - chainage_m: pipe.chainage_m, - reason: pipe.reason, - })), - ); - pipeEditor.setStrength(primaryRegion.strength_profile ?? []); - showRegion = true; - regionButton.classList.add("is-active"); - regionButton.setAttribute("aria-pressed", "true"); - status.textContent = regionSummary(primaryRegion); - } catch (error) { - status.textContent = - error instanceof Error ? error.message : "1차 배수유역을 확인하지 못했습니다."; - } finally { - regionButton.disabled = false; - scheduleDraw(); - } - } - - /** 상태줄에 띄울 1차 영역 요약 — 격자 셀 수를 보고 격자 크기를 조정할 근거가 된다. */ - function regionSummary(region: DrainagePrimaryRegion): string { - const cells = region.grid.cells.toLocaleString(); - const outside = - region.road_outside_m > 0 ? ` · 노선 이탈 ${Math.round(region.road_outside_m)}m` : ""; - const unknown = - region.flow && region.flow.unanalyzed > 0 - ? ` / 표고없음 ${region.flow.unanalyzed.toLocaleString()}(회)` - : ""; - const burned = - region.flow && region.flow.burned > 0 - ? ` · 세류망 새김 ${region.flow.burned.toLocaleString()}셀` - : ""; - const flow = region.flow - ? ` · 흐름 도로도달 ${region.flow.reaches_road.toLocaleString()}(적) / ` + - `미도달 ${region.flow.no_road.toLocaleString()}(청)${unknown}, ` + - `최외곽 출발 ${region.flow.outer_seeds.toLocaleString()} + ` + - `내부 보충 ${region.flow.interior_seeds.toLocaleString()}${burned}` - : " · 흐름 판정 없음"; - const expansion = region.expansion - ? ` · 확장 ${region.expansion.rounds}회` + - `(${region.expansion.initial_cells.toLocaleString()}→${cells}셀, ` + - `${region.expansion.closed ? "닫힘" : "상한 도달"})` - : ""; - const basin = region.basin_area_m2 - ? ` · 2차 유역 ${formatArea(region.basin_area_m2)}, 기본 관 ${region.pipes.length}개` - : ""; - return ( - `1차 영역(반경 ${region.radius_m}m): 상류망 ${region.upstream_lines.length}조각 채택 / ` + - `하류망 ${region.downstream_lines.length}조각·미연결 ${region.no_contact_count}개 제외 · ` + - `격자 ${region.grid.cell_m}m(도로 시점 기준) 셀 ${cells}개${outside}${expansion}${basin}${flow}` - ); - } - - regionButton.addEventListener("click", () => void toggleRegion()); - /** 노선 전체가 보이도록 배율·중심을 맞춘다. 노선이 없으면 도엽 전체를 그대로 보여준다. */ function fitToRoute(): void { scale = 1; @@ -705,12 +389,6 @@ export function createDrainagePanel(): DrainagePanel { const sequence = ++loadSequence; meta = null; preparedLayers.clear(); - // 1차 영역은 프로젝트·노선에 종속이므로 새로 불러올 때 버린다. - primaryRegion = null; - showRegion = false; - regionButton.classList.remove("is-active"); - regionButton.setAttribute("aria-pressed", "false"); - routeLayer = null; backgroundImage.removeAttribute("src"); status.hidden = false; status.textContent = "배경도를 불러오는 중…"; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts index 52c14e29..a7ab7101 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts @@ -24,8 +24,6 @@ const ADD_SNAP_PX = 14; export interface PipeEditor { setContext(meta: VWorldMeta | null, points: ReadonlyArray): void; setPipes(pipes: ReadonlyArray): void; - /** 도로 위 흐름 강도 [누가거리 m, 상류 면적 ㎡]. 관 추가 판단 근거로 계획선에 덧그린다. */ - setStrength(profile: ReadonlyArray): void; pipes(): ReadonlyArray; chainages(): number[]; selected(): number | null; @@ -35,8 +33,6 @@ export interface PipeEditor { handleDown(view: ViewState, screenX: number, screenY: number, editMode: boolean): boolean; handleMove(view: ViewState, screenX: number, screenY: number): boolean; handleUp(): boolean; - /** 계획선 위 흐름 강도 띠. 마커보다 아래에 깔아야 하므로 draw()와 따로 호출한다. */ - drawStrength(context: CanvasRenderingContext2D, view: ViewState): void; draw( context: CanvasRenderingContext2D, view: ViewState, @@ -52,9 +48,6 @@ export function createPipeEditor(onChange: () => void): PipeEditor { let selectedIndex: number | null = null; let draggingIndex: number | null = null; let dragMoved = false; - let strength: Array = []; - let strengthPeak = 0; - let strengthSpan = 5; /** 화면 → 사업지 좌표계 m (MapRender affine의 역변환). */ function screenToMetric( @@ -153,17 +146,6 @@ export function createPipeEditor(onChange: () => void): PipeEditor { selectedIndex = null; draggingIndex = null; }, - setStrength(profile) { - strength = profile.map((entry) => [entry[0], entry[1]] as const); - strengthPeak = strength.reduce((peak, entry) => Math.max(peak, entry[1]), 0); - // 표본 간격은 백엔드 출력 간격을 그대로 따른다(값이 0인 구간은 빠져 있으므로 최소 간격 사용). - let span = Infinity; - for (let i = 1; i < strength.length; i += 1) { - const gap = strength[i][0] - strength[i - 1][0]; - if (gap > 0 && gap < span) span = gap; - } - strengthSpan = Number.isFinite(span) ? span : 5; - }, pipes: () => pipeList, chainages: () => pipeList.map((pipe) => Math.round(pipe.chainage_m * 100) / 100), selected: () => selectedIndex, @@ -229,28 +211,6 @@ export function createPipeEditor(onChange: () => void): PipeEditor { } return true; }, - drawStrength(context, view) { - if (strengthPeak <= 0 || route.length < 2) return; - context.save(); - context.lineCap = "butt"; - strength.forEach(([chainage, area]) => { - const from = chainageToXY(chainage); - const to = chainageToXY(Math.min(totalChainage, chainage + strengthSpan)); - if (!from || !to) return; - const start = metricToScreen(view, from.x, from.y); - const end = metricToScreen(view, to.x, to.y); - if (!start || !end) return; - // 강도는 편차가 커서(계곡 한 점에 수십 배 집중) 제곱근으로 눌러 표시한다. - const intensity = Math.sqrt(area / strengthPeak); - context.beginPath(); - context.moveTo(start.x, start.y); - context.lineTo(end.x, end.y); - context.lineWidth = 3 + 9 * intensity; - context.strokeStyle = `rgba(37, 99, 235, ${(0.15 + 0.5 * intensity).toFixed(3)})`; - context.stroke(); - }); - context.restore(); - }, draw(context, view, colorOf) { pipeList.forEach((pipe, position) => { const xy = chainageToXY(pipe.chainage_m); diff --git a/B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Drainage_Watershed.py b/B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Drainage_Watershed.py deleted file mode 100644 index 1966d8b8..00000000 --- a/B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Drainage_Watershed.py +++ /dev/null @@ -1,572 +0,0 @@ -"""배수유역 산정 엔진 — 세류 기반 등고선 기하 직접 분석 (2026-07-29 합의). - -DEM 보간·D8 전역 흐름분석을 쓰지 않는다. 계산 순서(사용자 정의 7단계): -① 도로(노선)가 유역의 하측 경계 ② 도로를 가로지르는 세류 교차점에서 출발 -③ 세류 상류망을 추적하고 연관 등고선만 분석해 메인 유역 선정(하류 무의미) -④ 세류 교차점 = 관매설 지점 ⑤ 300m 초과 구간은 종단 저점에 보충(제안 엔진 담당) -⑥ 관 사이 물갈림 고개에서 오르는 분할선(능선 근사)으로 유역을 세분화하고 - 번호·면적·표고차·유하장을 산출 ⑦ 관 추가·경로 변경 시 재호출로 재분석. - -유역 폴리곤 = 도로 구간(하측) + 좌우 분할선 + 최상위 공통 등고선 아크(상측)로 폐합. -등고선은 STRtree에서 필요한 것만 꺼내므로 분석량이 유역 크기에 비례한다(도엽 매수 무관). -""" - -from __future__ import annotations - -import logging -import math -from dataclasses import dataclass, field -from typing import Any - -from shapely.geometry import LineString, Point, Polygon -from shapely.ops import substring, unary_union -from shapely.strtree import STRtree - -from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import ( - StructureCandidate, - _interpolate_vertex, - estimate_pipe_diameter_mm, -) -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Assemble import ( - _assemble_polygon, - _road_segment_coords, -) -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Subdivide import subdivide_main_polygon -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Trace import ( - LOCAL_MAX_STEPS, - LOCAL_SEARCH_RADIUS_M, - STREAM_JOIN_TOL_M, - ContourIndex, - DividerStep, - _explode_lines, - rim_walk, - side_sign, - trace_divider, - trace_ridge_march, - trace_upstream_network, - valley_region_polygon, -) - -logger = logging.getLogger(__name__) - -# 유효 유역 최소 면적(m²)과 상류망 커버 보정 버퍼(m). -MIN_BASIN_AREA_M2 = 100.0 -STREAM_COVER_BUFFER_M = 20.0 -# 도로 양끝에서 하류측으로 뻗는 절단 차단선 길이(m). -DOWNHILL_BARRIER_M = 800.0 -# 유역이 상류망을 덮어야 하는 커버리지 목표(미달 시 버퍼 폴백). -CLOSING_COVERAGE_GOAL = 0.95 -# 산측 판정: 도로 접선의 좌우 수직 오프셋(m)과 표고 조회 반경(m). -UPHILL_PROBE_OFFSET_M = 40.0 -UPHILL_PROBE_RADIUS_M = 60.0 - - -@dataclass -class WatershedBasin: - """능선 기반으로 산정된 배수유역 1개.""" - - index: int - chainage_m: float - outlet_x: float - outlet_y: float - # 유역 경계(사업지 좌표계 m). 외곽선의 산측이 곧 분수령(능선), 하측이 도로선. - boundary_xy: list[list[float]] = field(default_factory=list) - area_m2: float = 0.0 - relief_m: float = 0.0 - flow_length_m: float = 0.0 - pipe_diameter_mm: float | None = None - - -def _divide_chainages(vertices: list[Any], ordered: list[StructureCandidate]) -> list[float]: - """유역 분할 누가거리 목록(양끝 포함, 관 개수+1개). - - 인접한 두 관 사이 **종단 계획선의 최고점(물갈림 고개)**이 분할점이다 — - "도로에 닿은 물은 측구를 타고 내리막의 첫 관으로 들어간다". - """ - divides = [vertices[0].chainage_m] - for left, right in zip(ordered, ordered[1:]): - window = [ - vertex for vertex in vertices if left.chainage_m < vertex.chainage_m < right.chainage_m - ] - if window: - divides.append(max(window, key=lambda vertex: vertex.z).chainage_m) - else: - divides.append((left.chainage_m + right.chainage_m) / 2.0) - divides.append(vertices[-1].chainage_m) - return divides - - -def _uphill_sign_at(vertices: list[Any], chainage_m: float, contour_index: ContourIndex) -> int: - """해당 측점의 산측이 도로 진행방향 기준 좌(+1)인지 우(-1)인지. 불명이면 0.""" - x, y, _ = _interpolate_vertex(vertices, chainage_m) - back = _interpolate_vertex(vertices, max(0.0, chainage_m - 10.0)) - forward = _interpolate_vertex(vertices, chainage_m + 10.0) - dx, dy = forward[0] - back[0], forward[1] - back[1] - norm = math.hypot(dx, dy) - if norm < 1e-6: - return 0 - dx, dy = dx / norm, dy / norm - # 좌측 법선 (-dy, dx) 방향 오프셋이 side_sign +1에 대응한다. - left_z = contour_index.nearest_elevation( - Point(x - dy * UPHILL_PROBE_OFFSET_M, y + dx * UPHILL_PROBE_OFFSET_M), - UPHILL_PROBE_RADIUS_M, - ) - right_z = contour_index.nearest_elevation( - Point(x + dy * UPHILL_PROBE_OFFSET_M, y - dx * UPHILL_PROBE_OFFSET_M), - UPHILL_PROBE_RADIUS_M, - ) - if left_z is None or right_z is None or left_z == right_z: - return 0 - return 1 if left_z > right_z else -1 - - -def _clip_to_uphill( - polygon: Polygon, - road_line: LineString, - uphill_sign: int, - contour_index: ContourIndex, - keep_geom: Any = None, -) -> Polygon | None: - """도로 하류측 조각을 잘라낸다 — 도로가 유역의 한쪽 경계(2026-07-29 사용자 지시). - - 절단선 = 실제 도로선 + 양끝에서 **하류측으로 뻗는 수직 차단선** 2개. (후방 접선 - 연장은 곡선 노선에서 계곡 내부를 관통해 오절단을 일으켰다.) 노선 끝을 감아 도는 - 산측 사면은 남고, 도로 하류측 주머니만 분리된다. - 조각 분류는 표고 기반: 대표점의 등고선 표고 > 최근접 도로 지점 표고 → 산측. - keep_geom(세류 상류망)이 실제로 지나가는 조각은 무조건 유지한다. - """ - length = road_line.length - cutters: list[LineString] = [road_line] - for t_end, t_inner in ((0.0, min(30.0, length)), (length, max(0.0, length - 30.0))): - end = road_line.interpolate(t_end) - inner = road_line.interpolate(t_inner) - dx, dy = end.x - inner.x, end.y - inner.y - norm = math.hypot(dx, dy) or 1.0 - for nx, ny in ((-dy / norm, dx / norm), (dy / norm, -dx / norm)): - probe = Point(end.x + nx * 30.0, end.y + ny * 30.0) - if side_sign(road_line, probe) == -uphill_sign: - cutters.append( - LineString( - [ - (end.x, end.y), - (end.x + nx * DOWNHILL_BARRIER_M, end.y + ny * DOWNHILL_BARRIER_M), - ] - ) - ) - break - # split()은 절단선이 폴리곤 경계와 겹치면(도로 = 유역 하측 경계) 동작하지 않는다. - # 얇은 스트립을 차감해 조각을 분리하고, 분류 후 buffer-교집합으로 원형을 복원한다. - try: - strip = unary_union([cutter.buffer(0.5) for cutter in cutters]) - separated = polygon.difference(strip) - except Exception: # noqa: BLE001 - 절단 실패 시 원본 유지 - return polygon - pieces = [ - part - for part in (separated.geoms if separated.geom_type.startswith("Multi") else [separated]) - if part.geom_type == "Polygon" and not part.is_empty - ] - kept = [] - for piece in pieces: - if keep_geom is not None and piece.intersection(keep_geom).length > 5.0: - kept.append(piece) - continue - representative = piece.representative_point() - piece_z = contour_index.nearest_elevation(representative, 2.0 * UPHILL_PROBE_RADIUS_M) - foot = road_line.interpolate(road_line.project(representative)) - road_z = contour_index.nearest_elevation(foot, 2.0 * UPHILL_PROBE_RADIUS_M) - if piece_z is not None and road_z is not None and piece_z != road_z: - if piece_z > road_z: - kept.append(piece) - continue - # 표고 판정 불가(등고선 공백·동일 표고) 시에만 좌우 부호로 판정한다. - if side_sign(road_line, representative) == uphill_sign: - kept.append(piece) - if not kept: - return None - # 스트립 차감으로 깎인 0.5m를 되붙이되 원본 폴리곤 밖으로는 나가지 않는다. - merged = unary_union(kept).buffer(0.7).intersection(polygon).buffer(0) - if merged.geom_type == "MultiPolygon": - merged = max(merged.geoms, key=lambda part: part.area) - if merged.is_empty or merged.geom_type != "Polygon": - return None - return merged - - -def _assemble_march_polygon( - vertices: list[Any], - start_m: float, - end_m: float, - contour_index: ContourIndex, - road_line: LineString, - uphill_sign: int, - network_union: Any, - stream_lines: list[Any], - valley_top_z: float, -) -> Polygon | None: - """개선 2안 — 도로 양끝 물갈림점에서 능선 행진으로 경계를 직접 조립한다. - - 좌·우 능선 행진 체인(세류 제약이 분수령을 강제) + 발원부 위 공통 등고선 아크로 - 폐합(기존 `_assemble_polygon` 재사용). 상류망 커버리지가 목표 미달이면 None을 - 돌려 1안(등거리+체인) 폴백을 태운다. - """ - others = [line for line in stream_lines if line.distance(network_union) > STREAM_JOIN_TOL_M] - other_tree = STRtree(others) if others else None - sx, sy, _ = _interpolate_vertex(vertices, start_m) - ex, ey, _ = _interpolate_vertex(vertices, end_m) - left = trace_ridge_march( - Point(sx, sy), contour_index, network_union, others, other_tree, road_line, uphill_sign - ) - right = trace_ridge_march( - Point(ex, ey), contour_index, network_union, others, other_tree, road_line, uphill_sign - ) - if len(left) < 5 or len(right) < 5: - return None - # 상측 폐합: 우측 정상 → 좌측 정상을 능선마루 보행으로 잇는다. - rim = rim_walk( - right[-1].point, - left[-1].point, - contour_index, - network_union, - others, - other_tree, - road_line, - uphill_sign, - ) - if rim is None: - return None - ring = _road_segment_coords(vertices, start_m, end_m) - ring.extend((step.point.x, step.point.y) for step in right[1:]) - ring.extend((point.x, point.y) for point in rim) - ring.extend((step.point.x, step.point.y) for step in reversed(left[1:])) - if len(ring) < 4: - return None - polygon = Polygon(ring).buffer(0) - if polygon.geom_type == "MultiPolygon": - polygon = max(polygon.geoms, key=lambda part: part.area) - if polygon.is_empty or polygon.geom_type != "Polygon": - return None - coverage = network_union.intersection(polygon).length / max(network_union.length, 1.0) - if coverage < CLOSING_COVERAGE_GOAL: - return None - return polygon - - -def _refine_road_edge(polygon: Polygon, road_line: LineString) -> Polygon: - """경계의 도로변 구간을 도로선 원해상도 좌표로 치환한다. - - 단순화(simplify)가 도로변 경계를 뭉개 도로를 가로지르는 것을 막는다 - (2026-07-29 사용자 지시: 경계 참조를 도로선 해상도와 매칭). - """ - coords = list(polygon.exterior.coords)[:-1] - count = len(coords) - near = [road_line.distance(Point(c)) < 6.0 for c in coords] - if not any(near) or all(near): - return polygon - start = next(i for i in range(count) if not near[i]) - coords = coords[start:] + coords[:start] - near = near[start:] + near[:start] - ring: list[tuple[float, float]] = [] - i = 0 - while i < count: - if not near[i]: - ring.append(coords[i]) - i += 1 - continue - j = i - while j < count and near[j]: - j += 1 - t1 = road_line.project(Point(coords[i])) - t2 = road_line.project(Point(coords[j - 1])) - segment = substring(road_line, min(t1, t2), max(t1, t2)) - if segment.geom_type == "LineString" and len(segment.coords) >= 2: - segment_coords = list(segment.coords) - if t1 > t2: - segment_coords.reverse() - ring.extend(segment_coords) - else: - ring.extend(coords[i:j]) - i = j - if len(ring) < 4: - return polygon - refined = Polygon(ring).buffer(0) - if refined.geom_type == "MultiPolygon": - refined = max(refined.geoms, key=lambda part: part.area) - if refined.is_empty or refined.geom_type != "Polygon": - return polygon - return refined - - -def _main_watershed_polygon( - vertices: list[Any], - divides: list[float], - dividers: list[list[DividerStep]], - contour_index: ContourIndex, - road_line: LineString, - uphill_sign: int, - network_union: Any, - stream_lines: list[Any], - stream_features: list[dict[str, Any]], - outlet: Point, -) -> Polygon | None: - """메인 배수유역 폴리곤 1회 산정 — f72017a 채택 산식 그대로. - - 불변 조건(2026-07-30 사용자 지시): 이 함수의 산식은 전체 유역 경계를 결정하므로 - 변경 금지. 세분화는 이 결과를 내부에서만 쪼갠다(`subdivide_main_polygon`). - """ - valley_top_z = contour_index.max_elevation_within(network_union.buffer(10.0)) - # 개선 2안: 도로 양끝 물갈림점에서 능선 행진으로 경계를 직접 그린다. - polygon = None - if valley_top_z is not None: - polygon = _assemble_march_polygon( - vertices, - divides[0], - divides[-1], - contour_index, - road_line, - uphill_sign, - network_union, - stream_lines, - valley_top_z, - ) - if polygon is None: - # 개선 1안(폴백): 도로변 스트립 + 세류 계곡 영역(등거리+등고선 체인) 합집합. - base = _assemble_polygon( - vertices, - divides[0], - divides[-1], - dividers[0], - dividers[-1], - contour_index, - road_line, - network_union, - valley_top_z, - ) - valley = valley_region_polygon(network_union, stream_features, outlet, contour_index) - if base is None and valley is None: - return None - if base is not None and valley is not None: - merged = base.union(valley).buffer(0) - if merged.geom_type == "MultiPolygon": - merged = max(merged.geoms, key=lambda part: part.area) - polygon = merged if merged.geom_type == "Polygon" and not merged.is_empty else base - else: - polygon = base if base is not None else valley - coverage = network_union.intersection(polygon).length / max(network_union.length, 1.0) - if coverage < CLOSING_COVERAGE_GOAL: - # 계곡 영역이 못 덮은 상류망만 버퍼로 보정한다(최후 폴백). - covered = polygon.union(network_union.buffer(STREAM_COVER_BUFFER_M)).buffer(0) - if covered.geom_type == "MultiPolygon": - covered = max(covered.geoms, key=lambda part: part.area) - if covered.geom_type == "Polygon" and not covered.is_empty: - polygon = covered - # 마지막에 도로선으로 절단해 하류측을 제거한다 — 도로가 유역의 한쪽 경계. - polygon = _clip_to_uphill(polygon, road_line, uphill_sign, contour_index, network_union) - if polygon is None or polygon.area < MIN_BASIN_AREA_M2: - return None - return polygon - - -def _local_polygon( - vertices: list[Any], - divides: list[float], - dividers: list[list[DividerStep]], - position: int, - contour_index: ContourIndex, - road_line: LineString, - uphill_sign: int, -) -> Polygon | None: - """세류 없는 관 구간의 소범위 유역 — 도로 상측 첫 능선까지(기존 경로 유지).""" - base = _assemble_polygon( - vertices, - divides[position], - divides[position + 1], - dividers[position], - dividers[position + 1], - contour_index, - road_line, - None, - None, - ) - if base is None: - return None - return _clip_to_uphill(base, road_line, uphill_sign, contour_index, None) - - -def _basin_from_polygon( - polygon: Polygon, - candidate: StructureCandidate, - index: int, - flow_length: float, - contour_index: ContourIndex, - road_line: LineString, - vertices: list[Any], - simplify: bool = True, -) -> WatershedBasin: - """확정된 유역 폴리곤에서 산출값(면적·표고차·유하장·관경)을 계산한다. - - 세분화 조각(simplify=False)은 단순화하지 않는다 — 조각별 독립 단순화는 공유 - 분할선 경계를 어긋나게 해 겹침·틈을 만든다(배타적 타일링 유지). - """ - outlet = Point(candidate.x, candidate.y) - outlet_z = contour_index.nearest_elevation(outlet, UPHILL_PROBE_RADIUS_M) - if outlet_z is None: - outlet_z = _interpolate_vertex(vertices, candidate.chainage_m)[2] - # 표고차는 등고선만으로 계산한다(표고점 미참조 — 사용자 지시). - top_z = contour_index.max_elevation_within(polygon) or outlet_z - boundary_line = polygon.simplify(5.0, preserve_topology=True) if simplify else polygon - if boundary_line.is_empty or boundary_line.geom_type != "Polygon": - boundary_line = polygon - # 도로변 경계는 단순화 없이 도로선 해상도를 유지한다. - boundary_line = _refine_road_edge(boundary_line, road_line) - boundary = [[float(x), float(y)] for x, y in boundary_line.exterior.coords] - if flow_length <= 0.0: - flow_length = max( - (math.dist((candidate.x, candidate.y), point) for point in boundary), - default=0.0, - ) - basin = WatershedBasin( - index=index, - chainage_m=candidate.chainage_m, - outlet_x=candidate.x, - outlet_y=candidate.y, - boundary_xy=boundary, - area_m2=float(polygon.area), - relief_m=max(0.0, float(top_z) - float(outlet_z)), - flow_length_m=float(flow_length), - ) - basin.pipe_diameter_mm = estimate_pipe_diameter_mm( - basin.area_m2, basin.relief_m, basin.flow_length_m - ) - return basin - - -def build_watershed_basins( - vertices: list[Any], - candidates: list[StructureCandidate], - contour_features: list[dict[str, Any]], - spot_features: list[dict[str, Any]], - elevation_keys: tuple[str, ...], - stream_features: list[dict[str, Any]] | None = None, -) -> list[WatershedBasin]: - """메인 배수유역을 1회 산정하고 관 지점 기준으로 내부 세분화한다. - - 메인 유역 경계는 관 개수와 무관하게 항상 동일하다(불변 조건 — 2026-07-30 사용자 - 지시). 세부유역 = 메인 폴리곤을 관 사이 분할선으로 쪼갠 조각(배타적, 합집합 = - 메인). 세류 없는 관이 메인 범위 밖이면 소범위 유역을 별도 생성(기존 동작). - 번호는 노선 시점에 가까운 순. - """ - if not candidates or len(vertices) < 2: - return [] - contour_index = ContourIndex(contour_features, elevation_keys) - if contour_index.tree is None: - logger.warning("표고 속성이 있는 등고선이 없어 유역을 산정하지 못했습니다.") - return [] - # 표고점은 참조하지 않는다(2026-07-29 사용자 지시: 계측 측점 데이터라 오류 유입). - _ = spot_features - road_line = LineString([(vertex.x, vertex.y) for vertex in vertices]) - - ordered = sorted(candidates, key=lambda item: item.chainage_m) - divides = _divide_chainages(vertices, ordered) - signs = [_uphill_sign_at(vertices, item.chainage_m, contour_index) for item in ordered] - majority = 1 if sum(signs) >= 0 else -1 - signs = [sign or majority for sign in signs] - # 사용자 확정("confirmed") 측점도 세류에 닿아 있으면 세류 유역으로 취급한다. - stream_lines = _explode_lines(stream_features) if stream_features else [] - is_stream = [ - item.reason == "stream" - or any(line.distance(Point(item.x, item.y)) <= STREAM_JOIN_TOL_M for line in stream_lines) - for item in ordered - ] - - # 분할선은 인접 유역과 공유하므로 분할점마다 1회만 추적한다. - dividers: list[list[DividerStep]] = [] - for position, chainage in enumerate(divides): - neighbor_streams = [] - if position > 0: - neighbor_streams.append(is_stream[position - 1]) - if position < len(ordered): - neighbor_streams.append(is_stream[position]) - wide = any(neighbor_streams) - sign = signs[position - 1] if position > 0 else signs[0] - x, y, _ = _interpolate_vertex(vertices, chainage) - if wide: - steps = trace_divider(Point(x, y), contour_index, road_line, sign) - else: - steps = trace_divider( - Point(x, y), - contour_index, - road_line, - sign, - radius_m=LOCAL_SEARCH_RADIUS_M, - max_steps=LOCAL_MAX_STEPS, - ) - dividers.append(steps) - - # 관별 상류망은 1회만 추적한다(유하장 계산에도 사용). - networks: list[list[Any]] = [] - flows: list[float] = [] - for position, candidate in enumerate(ordered): - network: list[Any] = [] - flow_length = 0.0 - if is_stream[position] and stream_features: - network, flow_length = trace_upstream_network( - Point(candidate.x, candidate.y), stream_features, road_line, signs[position] - ) - networks.append(network) - flows.append(flow_length) - - main_polygon = None - combined = [line for network in networks for line in network] - first_stream = next((position for position in range(len(ordered)) if networks[position]), None) - if combined and first_stream is not None: - main_polygon = _main_watershed_polygon( - vertices, - divides, - dividers, - contour_index, - road_line, - majority, - unary_union(combined), - stream_lines, - stream_features or [], - Point(ordered[first_stream].x, ordered[first_stream].y), - ) - - pieces: list[Polygon | None] = [None] * len(ordered) - if main_polygon is not None: - divide_points = [ - Point(*_interpolate_vertex(vertices, chainage)[:2]) for chainage in divides - ] - pieces = subdivide_main_polygon(main_polygon, divide_points, dividers, road_line) - - basins: list[WatershedBasin] = [] - for position, candidate in enumerate(ordered): - polygon = pieces[position] if position < len(pieces) else None - from_subdivision = polygon is not None and len(ordered) > 1 - if polygon is None: - if networks[position] and main_polygon is not None: - logger.warning( - "세류 관(%.0fm) 구간에 세부유역 조각이 없습니다 — 건너뜁니다.", - candidate.chainage_m, - ) - continue - # 메인 유역 밖(또는 상류망 없음) 관은 소범위 유역을 별도 생성한다. - polygon = _local_polygon( - vertices, divides, dividers, position, contour_index, road_line, signs[position] - ) - if polygon is None or polygon.area < MIN_BASIN_AREA_M2: - continue - basins.append( - _basin_from_polygon( - polygon, - candidate, - len(basins) + 1, - flows[position], - contour_index, - road_line, - vertices, - simplify=not from_subdivision, - ) - ) - return basins diff --git a/B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Assemble.py b/B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Assemble.py deleted file mode 100644 index 8bff4e40..00000000 --- a/B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Assemble.py +++ /dev/null @@ -1,205 +0,0 @@ -"""배수유역 폴리곤 폐합 조립 — 도로 구간 + 분할선 + 등고선 아크 (700줄 분리, 2026-07-30). - -`B05_wf2_Route_Engine_Drainage_Watershed.py`에서 산식 변경 없이 그대로 옮겨온 -개선 1안(등거리+체인) 폐합 헬퍼 모음이다. 불변 조건: 산식 수정 금지(메인 유역 -경계가 바뀐다 — 2026-07-30 사용자 지시). -""" - -from __future__ import annotations - -from typing import Any - -from shapely.geometry import LineString, Point, Polygon -from shapely.ops import nearest_points, substring - -from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import _interpolate_vertex -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Trace import ContourIndex, DividerStep - -# 폐합 등고선 기하 탐색: 분할선 경로와 등고선의 근접 허용 거리(m). -CLOSING_SEARCH_M = 30.0 - - -def _road_segment_coords( - vertices: list[Any], start_m: float, end_m: float -) -> list[tuple[float, float]]: - """분할점 사이 도로 구간의 평면 좌표열(유역 폴리곤의 하측 경계).""" - sx, sy, _ = _interpolate_vertex(vertices, start_m) - ex, ey, _ = _interpolate_vertex(vertices, end_m) - coords = [(sx, sy)] - coords.extend( - (vertex.x, vertex.y) for vertex in vertices if start_m < vertex.chainage_m < end_m - ) - coords.append((ex, ey)) - return coords - - -def _contour_arc( - line: Any, - p_from: Point, - p_to: Point, - road_line: LineString, - network_union: Any = None, -) -> list[tuple[float, float]]: - """등고선에서 두 분할선 접점 사이 아크(상측 경계)를 뽑는다. - - 폐합 등고선은 두 방향 아크가 생기므로, 세류 상류망을 가로지르지 않고(계곡을 - 자르지 않고) 도로와도 교차하지 않는(=산측) 쪽을 고른다. - """ - t1, t2 = sorted((line.project(p_from), line.project(p_to))) - arcs = [] - inner = substring(line, t1, t2) - if inner.geom_type == "LineString" and len(inner.coords) >= 2: - arcs.append(inner) - if getattr(line, "is_closed", False): - head = substring(line, t2, line.length) - tail = substring(line, 0.0, t1) - coords = list(head.coords) + list(tail.coords)[1:] - if len(coords) >= 2: - arcs.append(LineString(coords)) - if not arcs: - return [] - scored = [] - for arc in arcs: - crosses_stream = bool(network_union is not None and arc.crosses(network_union)) - crosses_road = arc.crosses(road_line) - midpoint = arc.interpolate(0.5, normalized=True) - scored.append((crosses_stream, crosses_road, -road_line.distance(midpoint), arc)) - scored.sort(key=lambda item: (item[0], item[1], item[2])) - arc = scored[0][3] - coords = list(arc.coords) - if Point(coords[0]).distance(p_from) > Point(coords[-1]).distance(p_from): - coords.reverse() - return [(float(x), float(y)) for x, y in coords] - - -def _junction( - left: list[DividerStep], - right: list[DividerStep], - min_z: float | None = None, -) -> tuple[int, int, int] | None: - """두 분할선이 같은 등고선 지오메트리를 밟은 폐합 지점(좌 idx, 우 idx, geom idx). - - min_z(세류 상류망 최고 표고)가 있으면 그 **이상인 가장 낮은** 공통 등고선을 고른다 — - "세류로 영역을 지정한 뒤 가까운 등고선으로 바로 올려치면 안 된다"(2026-07-29 사용자 - 지시). 계곡 발원부를 넘긴 첫 등고선이 유역 상측 경계가 된다. 없으면 최고 공통 등고선. - """ - left_keys = { - (step.z, step.geom_index): position - for position, step in enumerate(left) - if step.geom_index >= 0 - } - matches: list[tuple[float, int, int, int]] = [] - for position, step in enumerate(right): - if step.geom_index < 0: - continue - left_position = left_keys.get((step.z, step.geom_index)) - if left_position is None: - continue - matches.append((step.z, left_position, position, step.geom_index)) - if not matches: - return None - if min_z is not None: - above = [match for match in matches if match[0] >= min_z] - if above: - best = min(above) - return best[1], best[2], best[3] - best = max(matches) - return best[1], best[2], best[3] - - -def _closing_contour( - left: list[DividerStep], - right: list[DividerStep], - contour_index: ContourIndex, - min_z: float, -) -> tuple[int, int, int, Point, Point] | None: - """두 분할선 경로에 모두 근접한 등고선 중 min_z 이상 최저를 찾는다. - - 분할선이 같은 스텝에서 같은 지오메트리를 밟지 못해도(도엽 분할 등) 계곡 발원부 - 위를 지나는 폐합 등고선을 기하적으로 찾아낸다. - 반환: (좌 절단 idx, 우 절단 idx, 등고선 geom idx, 좌 접점, 우 접점). - """ - if len(left) < 2 or len(right) < 2: - return None - left_line = LineString([step.point for step in left]) - right_line = LineString([step.point for step in right]) - shared = set(contour_index.query(left_line.buffer(CLOSING_SEARCH_M))) & set( - contour_index.query(right_line.buffer(CLOSING_SEARCH_M)) - ) - best: tuple[float, int] | None = None - for index in shared: - z = contour_index.zs[index] - if z < min_z: - continue - geom = contour_index.geoms[index] - if ( - geom.distance(left_line) > CLOSING_SEARCH_M - or geom.distance(right_line) > CLOSING_SEARCH_M - ): - continue - if best is None or z < best[0]: - best = (z, index) - if best is None: - return None - geom = contour_index.geoms[best[1]] - left_touch = nearest_points(geom, left_line)[0] - right_touch = nearest_points(geom, right_line)[0] - left_position = min(range(len(left)), key=lambda i: left[i].point.distance(left_touch)) - right_position = min(range(len(right)), key=lambda i: right[i].point.distance(right_touch)) - return left_position, right_position, best[1], left_touch, right_touch - - -def _assemble_polygon( - vertices: list[Any], - start_m: float, - end_m: float, - left: list[DividerStep], - right: list[DividerStep], - contour_index: ContourIndex, - road_line: LineString, - network_union: Any = None, - valley_top_z: float | None = None, -) -> Polygon | None: - """도로 구간 + 우측 분할선 + 상측 등고선 아크 + 좌측 분할선으로 폴리곤을 폐합한다. - - 세류 유역(valley_top_z 지정)은 발원부 위를 지나는 폐합 등고선을 기하 탐색으로 - 먼저 찾고, 실패 시 같은 스텝 매칭(_junction)으로 폐합한다. - """ - ring = _road_segment_coords(vertices, start_m, end_m) - left_used, right_used, arc = left, right, [] - closure = ( - _closing_contour(left, right, contour_index, valley_top_z) - if valley_top_z is not None - else None - ) - if closure is not None: - left_position, right_position, geom_index, left_touch, right_touch = closure - left_used = left[: left_position + 1] - right_used = right[: right_position + 1] - arc = _contour_arc( - contour_index.geoms[geom_index], right_touch, left_touch, road_line, network_union - ) - else: - junction = _junction(left, right, min_z=valley_top_z) - if junction is not None: - left_position, right_position, geom_index = junction - left_used = left[: left_position + 1] - right_used = right[: right_position + 1] - arc = _contour_arc( - contour_index.geoms[geom_index], - right_used[-1].point, - left_used[-1].point, - road_line, - network_union, - ) - ring.extend((step.point.x, step.point.y) for step in right_used[1:]) - ring.extend(arc) - ring.extend((step.point.x, step.point.y) for step in reversed(left_used[1:])) - if len(ring) < 4: - return None - polygon = Polygon(ring).buffer(0) - if polygon.geom_type == "MultiPolygon": - polygon = max(polygon.geoms, key=lambda part: part.area) - if polygon.is_empty or polygon.geom_type != "Polygon": - return None - return polygon diff --git a/B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Subdivide.py b/B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Subdivide.py deleted file mode 100644 index 8efdb68f..00000000 --- a/B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Subdivide.py +++ /dev/null @@ -1,171 +0,0 @@ -"""메인 배수유역 내부 세분화 — 분할선으로 폴리곤을 쪼갠다 (2026-07-30). - -불변 조건(사용자 지시): 전체(메인) 배수유역 경계는 절대 변경하지 않는다. -세분화는 확정된 메인 유역 폴리곤을 관 사이 분할선(물갈림 고개에서 오르는 -능선 근사선)으로 **내부에서만** 쪼개는 방식이다 — 외곽 재추적 금지. -따라서 세부유역은 서로 배타적이고 합집합은 항상 메인 유역과 동일하다. -""" - -from __future__ import annotations - -import logging -import math - -from shapely.geometry import LineString, Point, Polygon -from shapely.ops import split as shapely_split -from shapely.ops import substring, unary_union - -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Trace import DividerStep - -logger = logging.getLogger(__name__) - -# 분할 절단선 연장: 도로 하류측(m)과 능선 너머(폴리곤 대각선 배수). -CUT_ROAD_TAIL_M = 40.0 -CUT_RIDGE_EXTEND_RATIO = 1.5 - - -def _cut_line( - road_point: Point, - steps: list[DividerStep], - road_line: LineString, - main_polygon: Polygon, -) -> LineString | None: - """분할선 스텝을 절단선으로 확장한다 — 도로 하류측과 능선 너머까지 관통. - - split()은 절단선이 폴리곤 경계를 완전히 넘어야 동작하므로 양끝을 연장한다. - 스텝이 없으면(등고선 공백) 도로 법선 직선으로 폴백한다. - """ - bounds = main_polygon.bounds - reach = CUT_RIDGE_EXTEND_RATIO * math.hypot(bounds[2] - bounds[0], bounds[3] - bounds[1]) - points = [(road_point.x, road_point.y)] - points.extend((step.point.x, step.point.y) for step in steps if step.geom_index >= 0) - if len(points) < 2: - # 폴백: 도로 접선의 법선 방향으로 폴리곤을 관통하는 직선. - t = road_line.project(road_point) - a = road_line.interpolate(max(0.0, t - 5.0)) - b = road_line.interpolate(min(road_line.length, t + 5.0)) - dx, dy = b.x - a.x, b.y - a.y - norm = math.hypot(dx, dy) - if norm < 1e-6: - return None - nx, ny = -dy / norm, dx / norm - head = (road_point.x + nx * reach, road_point.y + ny * reach) - tail = (road_point.x - nx * reach, road_point.y - ny * reach) - return LineString([tail, (road_point.x, road_point.y), head]) - # 능선 너머 연장: 마지막 진행 방향 유지. - (px, py), (qx, qy) = points[-2], points[-1] - dx, dy = qx - px, qy - py - norm = math.hypot(dx, dy) - if norm >= 1e-6: - points.append((qx + dx / norm * reach, qy + dy / norm * reach)) - # 도로 하류측 연장: 첫 스텝 → 도로점 방향을 그대로 지나쳐 내려간다. - (fx, fy) = points[1] - dx, dy = road_point.x - fx, road_point.y - fy - norm = math.hypot(dx, dy) - if norm >= 1e-6: - points.insert( - 0, - ( - road_point.x + dx / norm * CUT_ROAD_TAIL_M, - road_point.y + dy / norm * CUT_ROAD_TAIL_M, - ), - ) - return LineString(points) - - -def _interval_index(piece: Polygon, road_line: LineString, divide_ts: list[float]) -> int: - """조각이 어느 관 구간(k)에 속하는지 — 구간 도로와 맞닿는 길이가 최대인 곳. - - 대표점 투영은 대형 계곡 조각에서 오판한다(상류로 길게 뻗은 조각의 대표점이 - 엉뚱한 구간에 떨어짐). 도로 접촉이 전혀 없는 조각만 대표점 투영으로 폴백. - """ - strip = piece.buffer(1.0) - best_k, best_length = -1, 0.0 - for k in range(len(divide_ts) - 1): - segment = substring(road_line, divide_ts[k], divide_ts[k + 1]) - if segment.is_empty: - continue - length = segment.intersection(strip).length - if length > best_length: - best_k, best_length = k, length - if best_k >= 0: - return best_k - t = road_line.project(piece.representative_point()) - for k in range(len(divide_ts) - 1): - if divide_ts[k] <= t <= divide_ts[k + 1]: - return k - return 0 if t < divide_ts[0] else len(divide_ts) - 2 - - -def subdivide_main_polygon( - main_polygon: Polygon, - divide_points: list[Point], - dividers: list[list[DividerStep]], - road_line: LineString, -) -> list[Polygon | None]: - """메인 유역 폴리곤을 내부 분할선으로 쪼개 관 구간별 조각을 돌려준다. - - 반환 길이 = 관 개수(구간 수). 조각이 없는 구간은 None. - split() 기반이므로 조각들은 배타적이고 합집합 == 메인 폴리곤이 보장된다. - 구간에 여러 조각이 잡히면(절단선 재진입) 모두 합쳐 가장 큰 폴리곤을 쓴다. - """ - interval_count = len(divide_points) - 1 - if interval_count <= 1: - return [main_polygon] - pieces: list[Polygon] = [main_polygon] - for position in range(1, interval_count): - cut = _cut_line(divide_points[position], dividers[position], road_line, main_polygon) - if cut is None: - logger.warning("분할 절단선 생성 실패(구간 %d) — 해당 분할을 건너뜁니다.", position) - continue - next_pieces: list[Polygon] = [] - for piece in pieces: - try: - parts = shapely_split(piece, cut) - except Exception: # noqa: BLE001 - 절단 실패 시 조각 유지 - next_pieces.append(piece) - continue - split_parts = [ - part - for part in getattr(parts, "geoms", [parts]) - if part.geom_type == "Polygon" and not part.is_empty - ] - next_pieces.extend(split_parts if split_parts else [piece]) - pieces = next_pieces - divide_ts = [road_line.project(point) for point in divide_points] - assigned: list[list[Polygon]] = [[] for _ in range(interval_count)] - for piece in pieces: - assigned[_interval_index(piece, road_line, divide_ts)].append(piece) - # 구간별 대표 조각 = 최대 조각과 그에 붙는 조각들. 비연결 잔여 조각은 버리지 - # 않고(합집합 불변 조건) 맞닿는 인접 구간으로 재배정한다. - result: list[Polygon | None] = [] - leftovers: list[Polygon] = [] - for group in assigned: - merged = _merge_touching(group) - result.append(merged[0] if merged else None) - leftovers.extend(merged[1:]) - for extra in leftovers: - for position in sorted( - range(interval_count), - key=lambda k: extra.distance(result[k]) if result[k] is not None else math.inf, - ): - base = result[position] - if base is None or not extra.touches(base): - continue - candidate = base.union(extra).buffer(0) - if candidate.geom_type == "Polygon": - result[position] = candidate - break - else: - logger.warning("세분화 잔여 조각(%.0f m²)을 재배정하지 못해 제외합니다.", extra.area) - return result - - -def _merge_touching(group: list[Polygon]) -> list[Polygon]: - """조각 묶음을 서로 맞닿는 것끼리 합쳐 면적 내림차순으로 돌려준다.""" - if not group: - return [] - merged = unary_union(group).buffer(0) - parts = list(merged.geoms) if merged.geom_type == "MultiPolygon" else [merged] - parts = [part for part in parts if part.geom_type == "Polygon" and not part.is_empty] - return sorted(parts, key=lambda part: part.area, reverse=True) diff --git a/B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Trace.py b/B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Trace.py deleted file mode 100644 index a6de8c24..00000000 --- a/B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Trace.py +++ /dev/null @@ -1,657 +0,0 @@ -"""배수유역 추적 유틸 — 등고선 공간 인덱스·세류 상류망·분수령(능선) 추적. - -등고선 기하 직접 분석(2026-07-29 합의)의 하위 도구 모음. DEM 보간 없이: -- `ContourIndex`: 등고선(선)·표고점(점)을 STRtree에 1회 적재하고 필요한 것만 꺼낸다. -- `trace_upstream_network`: 세류 교차점에서 도로 산측 상류망만 추적한다(하류 무시). -- `trace_divider`: 물갈림 지점에서 상향 등고선을 한 겹씩 따라 오르는 유역 분할선(능선 근사). - -전체 등고선을 순회하는 연산을 두지 않아 분석량이 유역 크기에 비례한다(도엽 매수와 무관). -""" - -from __future__ import annotations - -import bisect -from dataclasses import dataclass -from typing import Any - -from shapely.geometry import LineString, Point, Polygon, box, shape -from shapely.ops import nearest_points, substring, unary_union -from shapely.strtree import STRtree - -# 분할선(능선 근사) 추적: 다음 상위 등고선을 찾는 탐색 반경(m)과 최대 단계 수. -DIVIDER_SEARCH_RADIUS_M = 120.0 -DIVIDER_MAX_STEPS = 60 -# 세류 없는 소규모 유역: 도로 상측 첫 능선까지만 오르도록 좁힌 한계(영역 선정 주의). -LOCAL_SEARCH_RADIUS_M = 80.0 -LOCAL_MAX_STEPS = 12 -# 세류 연결 판정 이격(m)과 상류망 총연장 상한(m). -STREAM_JOIN_TOL_M = 15.0 -MAX_UPSTREAM_TOTAL_M = 5000.0 -# 계곡 유역 근사 격자: 셀 크기(m)와 상류망에서의 최대 이격(m). -VALLEY_CELL_M = 12.0 -VALLEY_CAP_M = 350.0 -# 능선 스냅: 경계 정점에서 등고선 탐색 반경(m)과 등고선 위 능선 꼭짓점 탐색 폭(m). -RIDGE_SNAP_M = 40.0 -RIDGE_WALK_M = 80.0 -# 능선 행진(개선 2안): 다음 상위 등고선 탐색 반경(m)·등고선 위 꼭짓점 탐색 폭(m)·최대 단계. -# 탐색 폭을 좁게 유지해야 체인이 자기 능선을 국소 추종한다(넓으면 이웃 능선으로 가로 이탈). -MARCH_RADIUS_M = 120.0 -MARCH_WALK_M = 40.0 -MARCH_MAX_STEPS = 150 -# 첫 스텝(시드)만 넓게 탐색 — 물갈림점이 능선 위가 아닐 수 있어 국소 분수령을 먼저 찾는다. -MARCH_SEED_WALK_M = 250.0 -# 이탈 제약 완화 비율 — 등거리선(dn=do) 부근에서 체인이 멈추지 않게 소폭 허용. -MARCH_OTHER_RATIO = 0.7 - - -@dataclass -class DividerStep: - """분할선의 한 단계 — 어느 등고선(geom_index)의 어느 지점을 밟았는지.""" - - point: Point - z: float - geom_index: int - - -class ContourIndex: - """표고 속성이 있는 등고선·표고점 피처의 STRtree 래퍼.""" - - def __init__(self, features: list[dict[str, Any]], elevation_keys: tuple[str, ...]) -> None: - self.geoms: list[Any] = [] - self.zs: list[float] = [] - for feature in features: - elevation = _feature_elevation(feature, elevation_keys) - if elevation is None: - continue - geometry = feature.get("geometry") or {} - try: - geom = shape(geometry) - except Exception: # noqa: BLE001 - 손상된 피처는 건너뛴다 - continue - if geom.is_empty: - continue - parts = list(geom.geoms) if geom.geom_type.startswith("Multi") else [geom] - for part in parts: - self.geoms.append(part) - self.zs.append(elevation) - self.tree = STRtree(self.geoms) if self.geoms else None - - def query(self, geometry: Any) -> list[int]: - """geometry 근방(bbox 교차) 피처의 인덱스만 돌려준다.""" - if self.tree is None: - return [] - return [int(i) for i in self.tree.query(geometry)] - - def nearest_elevation(self, point: Point, radius_m: float) -> float | None: - """point에서 radius 안 가장 가까운 피처의 표고. 없으면 None.""" - best_z: float | None = None - best_distance = radius_m - for index in self.query(point.buffer(radius_m)): - distance = self.geoms[index].distance(point) - if distance <= best_distance: - best_distance = distance - best_z = self.zs[index] - return best_z - - def max_elevation_within(self, polygon: Any) -> float | None: - """polygon과 실제로 교차하는 피처들의 최고 표고.""" - best: float | None = None - for index in self.query(polygon): - if not polygon.intersects(self.geoms[index]): - continue - if best is None or self.zs[index] > best: - best = self.zs[index] - return best - - -def _feature_elevation(feature: dict[str, Any], elevation_keys: tuple[str, ...]) -> float | None: - properties = feature.get("properties") or {} - for key in elevation_keys: - value = properties.get(key) - if value is None: - continue - try: - return float(value) - except (TypeError, ValueError): - continue - return None - - -def side_sign(road_line: LineString, point: Point) -> int: - """도로선 기준 point가 어느 쪽인지(+1/-1, 선상이면 0). 국소 접선과의 외적 부호.""" - t = road_line.project(point) - a = road_line.interpolate(max(0.0, t - 5.0)) - b = road_line.interpolate(min(road_line.length, t + 5.0)) - cross = (b.x - a.x) * (point.y - a.y) - (b.y - a.y) * (point.x - a.x) - if cross > 0: - return 1 - if cross < 0: - return -1 - return 0 - - -def trace_divider( - start: Point, - contour_index: ContourIndex, - road_line: LineString, - uphill_sign: int, - radius_m: float = DIVIDER_SEARCH_RADIUS_M, - max_steps: int = DIVIDER_MAX_STEPS, -) -> list[DividerStep]: - """물갈림 지점에서 상향 등고선을 한 겹씩 밟아 오르는 분할선을 만든다. - - 각 단계에서 반경 안의 "현재보다 높은 등고선 중 가장 낮은 것"의 최근접점으로 이동한다. - 도로 산측(uphill_sign)을 벗어나거나 도로 쪽으로 되돌아가는 이동은 막는다. - 더 높은 등고선이 반경 안에 없으면 능선(분수령)에 닿은 것으로 보고 멈춘다. - """ - z = contour_index.nearest_elevation(start, radius_m) - if z is None: - return [] - steps = [DividerStep(point=start, z=z, geom_index=-1)] - current = start - road_distance = road_line.distance(start) - for _ in range(max_steps): - best: tuple[float, float, Point, int] | None = None - for index in contour_index.query(current.buffer(radius_m)): - candidate_z = contour_index.zs[index] - if candidate_z <= z + 0.01: - continue - if best is not None and candidate_z > best[0]: - continue - point = nearest_points(contour_index.geoms[index], current)[0] - distance = current.distance(point) - if distance > radius_m: - continue - # 도로 반대편·도로 방향 후퇴 금지 — 분할선은 산측으로만 오른다. - if side_sign(road_line, point) == -uphill_sign: - continue - if road_line.distance(point) + 1.0 < road_distance: - continue - if ( - best is None - or candidate_z < best[0] - or (candidate_z == best[0] and distance < best[1]) - ): - best = (candidate_z, distance, point, index) - if best is None: - break - z, _, current, geom_index = best - road_distance = max(road_distance, road_line.distance(current)) - steps.append(DividerStep(point=current, z=z, geom_index=geom_index)) - return steps - - -def _explode_lines(stream_features: list[dict[str, Any]]) -> list[LineString]: - lines: list[LineString] = [] - for feature in stream_features: - geometry = feature.get("geometry") or {} - try: - geom = shape(geometry) - except Exception: # noqa: BLE001 - continue - if geom.is_empty: - continue - parts = list(geom.geoms) if geom.geom_type.startswith("Multi") else [geom] - lines.extend(part for part in parts if part.geom_type == "LineString") - return lines - - -def _oriented_from(line: LineString, origin: Point) -> LineString: - """origin에 가까운 끝이 시작점이 되도록 방향을 맞춘다.""" - if Point(line.coords[0]).distance(origin) <= Point(line.coords[-1]).distance(origin): - return line - return LineString(list(line.coords)[::-1]) - - -def _clip_uphill(line: LineString, road_line: LineString, origin: Point) -> LineString | None: - """도로를 다시 가로지르면 교차 지점에서 잘라 origin 쪽 조각만 남긴다.""" - if not line.crosses(road_line): - return line - t = line.project(nearest_points(line.intersection(road_line), origin)[0]) - piece = substring(line, 0.0, t) if line.project(origin) < t else substring(line, t, line.length) - if piece.geom_type != "LineString" or piece.length < 1.0: - return None - return piece - - -def valley_region_polygon( - network_union: Any, - stream_features: list[dict[str, Any]], - crossing: Point, - contour_index: Any = None, -) -> Any | None: - """상류망 계곡의 유역 영역 — 경계는 등고선을 참고한 능선(분수령)으로 긋는다. - - ① 등거리 1차 근사: 격자 셀 중심이 인접 계곡 세류보다 우리 상류망에 가깝고 상한 - 거리 이내이면 유역 소속. 지류 사이 사면(지능선) 포함, 인접 계곡 자동 제외. - ② 등고선 스냅(2026-07-29 사용자 지시): 경계는 세류들 사이 중간 어딘가가 아니라 - **등고선을 참고해** 그어야 한다 — 각 경계 정점을 근처 등고선 위에서 두 세류 - 모두로부터 가장 먼 지점(능선 꼭짓점)으로 이동. 단 우리 세류를 침범하거나 - 인접 세류 너머로 나가지 않는다. - 반환: 폴리곤(간략화됨) 또는 None. - """ - lines = _explode_lines(stream_features) - others = [line for line in lines if line.distance(network_union) > STREAM_JOIN_TOL_M] - other_tree = STRtree(others) if others else None - min_x, min_y, max_x, max_y = network_union.buffer(VALLEY_CAP_M).bounds - cells = [] - y = min_y - while y < max_y: - x = min_x - while x < max_x: - center = Point(x + VALLEY_CELL_M / 2.0, y + VALLEY_CELL_M / 2.0) - distance = network_union.distance(center) - if distance <= VALLEY_CAP_M: - if other_tree is not None: - nearest = others[int(other_tree.nearest(center))] - if nearest.distance(center) < distance: - x += VALLEY_CELL_M - continue - cells.append(box(x, y, x + VALLEY_CELL_M, y + VALLEY_CELL_M)) - x += VALLEY_CELL_M - y += VALLEY_CELL_M - if not cells: - return None - region = unary_union(cells).buffer(0) - if region.geom_type == "MultiPolygon": - touching = [ - part - for part in region.geoms - if part.intersects(network_union) or part.distance(crossing) < VALLEY_CELL_M * 2 - ] - region = unary_union(touching) if touching else max(region.geoms, key=lambda p: p.area) - if region.geom_type == "MultiPolygon": - region = max(region.geoms, key=lambda p: p.area) - region = region.simplify(VALLEY_CELL_M, preserve_topology=True) - if region.is_empty or region.geom_type != "Polygon": - return None - if contour_index is not None: - region = _contour_chain_boundary(region, contour_index, network_union, others, other_tree) - return region - - -def _collect_intersection_points(geometry: Any) -> list[Point]: - """교차 결과에서 대표 점들을 뽑는다.""" - if geometry.is_empty: - return [] - if geometry.geom_type == "Point": - return [geometry] - if geometry.geom_type in {"MultiPoint", "GeometryCollection"}: - points: list[Point] = [] - for part in geometry.geoms: - points.extend(_collect_intersection_points(part)) - return points - if geometry.geom_type in {"LineString", "MultiLineString"}: - return [geometry.interpolate(0.5, normalized=True)] - return [] - - -def _contour_chain_boundary( - region: Any, - contour_index: Any, - network_union: Any, - others: list[LineString], - other_tree: STRtree | None, -) -> Any: - """유역 경계를 **등고선마다 능선 포인트 1개씩 찍어 연결**한 체인으로 재구성한다. - - (2026-07-29 사용자 지시: 정점 스냅은 점이 듬성듬성해 등고선을 건너뛴다.) - 등거리 1차 경계 링은 순서 뼈대로만 쓴다: 링을 가로지르는 모든 등고선 교차점마다 - 그 등고선 위에서 두 세류(우리 상류망·인접 세류) 모두로부터 가장 먼 지점(능선 - 꼭짓점)을 정제해 포인트를 얻고, 링 위 위치 순으로 연결한다. 등고선이 없는 구간은 - 원래 링 정점으로 메운다. 제약: 우리 세류 침범·인접 세류 이탈 금지. - - 2차 보정(2026-07-29 사용자 채택, "2번 방식"): 능선은 등고선의 **직교 궤적**이므로, - 각 포인트를 등고선 위에서 미세 이동해 경계선이 그 등고선과 수직으로 교차하도록 - 반복 조정한다(TOPOG/TAPES-C 계열 개념). 능선 점수(세류 최소거리)가 꼭짓점 대비 - 크게 떨어지는 이동은 막는다. - """ - ring = LineString(region.exterior.coords) - - def _score(point: Point) -> tuple[float, float]: - to_network = network_union.distance(point) - to_other = ( - others[int(other_tree.nearest(point))].distance(point) - if other_tree is not None - else float("inf") - ) - return to_network, to_other - - # ① 링을 가로지르는 등고선 교차점마다 능선 꼭짓점 1개. - # entry = [링 위치 s, Point, geom_index(-1=링 정점), 등고선 파라미터 t, 꼭짓점 점수] - entries: list[list[Any]] = [] - for index in contour_index.query(ring.buffer(1.0)): - geom = contour_index.geoms[index] - try: - crossings = _collect_intersection_points(geom.intersection(ring)) - except Exception: # noqa: BLE001 - continue - for crossing in crossings: - s = ring.project(crossing) - t0 = geom.project(crossing) - best = None - best_t = t0 - best_value = -1.0 - steps = int(RIDGE_WALK_M / 10.0) - for offset in [0.0] + [ - sign * k * 10.0 for k in range(1, steps + 1) for sign in (1, -1) - ]: - t = min(max(t0 + offset, 0.0), geom.length) - candidate = geom.interpolate(t) - if candidate.distance(crossing) > RIDGE_WALK_M + RIDGE_SNAP_M: - continue - to_network, to_other = _score(candidate) - if to_network < STREAM_JOIN_TOL_M: - continue # 우리 세류 침범 금지 - if to_other < to_network: - continue # 인접 세류 쪽으로 이탈 금지 - if min(to_network, to_other) > best_value: - best_value = min(to_network, to_other) - best = candidate - best_t = t - if best is not None: - entries.append([s, best, index, best_t, best_value]) - if len(entries) < 4: - return region - entries.sort(key=lambda entry: entry[0]) - # ② 등고선 공백 구간(교차점 사이가 먼 곳)은 원래 링 정점으로 메운다. - positions = [entry[0] for entry in entries] - for x, y in list(region.exterior.coords)[:-1]: - s = ring.project(Point(x, y)) - slot = bisect.bisect_left(positions, s) - before = positions[slot - 1] if slot > 0 else positions[-1] - ring.length - after = positions[slot] if slot < len(positions) else positions[0] + ring.length - if min(s - before, after - s) > VALLEY_CELL_M * 2.5: - entries.append([s, Point(x, y), -1, 0.0, 0.0]) - entries.sort(key=lambda entry: entry[0]) - # ③ 직교 보정: 경계 진행방향과 등고선 접선이 수직이 되도록 포인트를 미세 이동. - entries = _orthogonalize_chain(entries, contour_index, _score) - polygon = Polygon([(entry[1].x, entry[1].y) for entry in entries]).buffer(0) - if polygon.geom_type == "MultiPolygon": - polygon = max(polygon.geoms, key=lambda part: part.area) - if polygon.is_empty or polygon.geom_type != "Polygon": - return region - return polygon - - -def _orthogonalize_chain( - entries: list[list[Any]], - contour_index: Any, - score: Any, -) -> list[list[Any]]: - """체인 포인트를 등고선 위에서 이동해 경계가 등고선과 직교하게 만든다. - - 각 포인트에서 |등고선 접선 · 체인 진행방향| (수직이면 0)을 최소화한다. 이동 허용 - 조건: 세류 침범·이탈 금지 + 능선 점수(두 세류 최소거리)가 꼭짓점 값의 70% 이상. - 2회 반복으로 이웃 이동의 영향을 수렴시킨다. - """ - count = len(entries) - for _ in range(2): - for i, entry in enumerate(entries): - geom_index = entry[2] - if geom_index < 0: - continue - geom = contour_index.geoms[geom_index] - previous = entries[i - 1][1] - following = entries[(i + 1) % count][1] - dx, dy = following.x - previous.x, following.y - previous.y - norm = (dx * dx + dy * dy) ** 0.5 - if norm < 1.0: - continue - dx, dy = dx / norm, dy / norm - floor = 0.7 * entry[4] - best_t = entry[3] - best_point = entry[1] - best_dot = None - for offset in range(-int(RIDGE_SNAP_M), int(RIDGE_SNAP_M) + 1, 5): - t = min(max(entry[3] + float(offset), 0.0), geom.length) - candidate = geom.interpolate(t) - ahead = geom.interpolate(min(t + 4.0, geom.length)) - behind = geom.interpolate(max(t - 4.0, 0.0)) - tx, ty = ahead.x - behind.x, ahead.y - behind.y - tangent_norm = (tx * tx + ty * ty) ** 0.5 - if tangent_norm < 0.5: - continue - to_network, to_other = score(candidate) - if to_network < STREAM_JOIN_TOL_M or to_other < to_network: - continue - if min(to_network, to_other) < floor: - continue - dot = abs((tx * dx + ty * dy) / tangent_norm) - if best_dot is None or dot < best_dot: - best_dot = dot - best_t = t - best_point = candidate - entry[1] = best_point - entry[3] = best_t - return entries - - -def trace_ridge_march( - start: Point, - contour_index: Any, - network_union: Any, - others: list[LineString], - other_tree: STRtree | None, - road_line: LineString, - uphill_sign: int, -) -> list[DividerStep]: - """능선 행진(개선 2안) — 상위 등고선마다 능선 꼭짓점을 한 칸씩 밟아 오른다. - - 수작업 유역도 작도법의 자동화: 물갈림점에서 출발해 매 단계 "반경 안 현재보다 높은 - 등고선 중 가장 낮은 것" 위에서 **|우리 상류망까지 거리 − 인접 세류까지 거리|가 - 최소인 지점**(분수령 = 두 세류망 등거리점)으로 이동한다. 두 세류에서 가장 먼 점을 - 고르면 우리 지류들 사이 내부 지능선으로 새므로, 등거리 조건이 바깥 분수령을 강제 - 한다. 꼭짓점 연결선은 등고선과 자연히 직교한다. - 제약: 도로 산측 유지, 우리 세류 침범(15m)·인접 세류 과이탈 금지. 더 높은 등고선이 - 없으면(능선 정상) 자연 종료. 반환은 DividerStep 목록 — 기존 폐합 로직과 호환. - """ - - def _score(point: Point) -> tuple[float, float]: - to_network = network_union.distance(point) - to_other = ( - others[int(other_tree.nearest(point))].distance(point) - if other_tree is not None - else float("inf") - ) - return to_network, to_other - - z = contour_index.nearest_elevation(start, MARCH_RADIUS_M) - if z is None: - return [] - steps_out = [DividerStep(point=start, z=z, geom_index=-1)] - current = start - for step_no in range(MARCH_MAX_STEPS): - walk_m = MARCH_SEED_WALK_M if step_no == 0 else MARCH_WALK_M - reach_m = max(MARCH_RADIUS_M, walk_m) - best: tuple[float, float, Point, int] | None = None # (레벨, |dn-do|, 지점, geom idx) - for index in contour_index.query(current.buffer(reach_m)): - level = contour_index.zs[index] - if level <= z + 0.01: - continue - if best is not None and level > best[0]: - continue - geom = contour_index.geoms[index] - if geom.distance(current) > reach_m: - continue - t0 = geom.project(current) - walk = int(walk_m / 10.0) - for offset in [0.0] + [sign * k * 10.0 for k in range(1, walk + 1) for sign in (1, -1)]: - t = min(max(t0 + offset, 0.0), geom.length) - candidate = geom.interpolate(t) - if candidate.distance(current) > reach_m: - continue - # 도로 하류측 이탈 금지 — 단, 노선 끝 너머(투영이 끝점에 걸림)는 좌우 - # 부호가 무의미하므로 세류 제약에만 맡긴다(끝을 감아 도는 분수령 허용). - projection = road_line.project(candidate) - if ( - 5.0 < projection < road_line.length - 5.0 - and side_sign(road_line, candidate) == -uphill_sign - ): - continue - to_network, to_other = _score(candidate) - if to_network < STREAM_JOIN_TOL_M: - continue # 우리 세류 침범 금지 - if to_other < to_network * MARCH_OTHER_RATIO: - continue # 인접 세류 쪽 과이탈 금지(등거리선 부근 소폭 허용) - balance = abs(to_network - to_other) - if best is None or level < best[0] or (level == best[0] and balance < best[1]): - best = (level, balance, candidate, index) - if best is None: - break - z = best[0] - current = best[2] - steps_out.append(DividerStep(point=current, z=z, geom_index=best[3])) - return steps_out - - -def rim_walk( - start: Point, - target: Point, - contour_index: Any, - network_union: Any, - others: list[LineString], - other_tree: STRtree | None, - road_line: LineString, - uphill_sign: int, -) -> list[Point] | None: - """능선마루를 따라 두 행진 정상을 잇는다(개선 2안 상측 폐합). - - 좌·우 능선 정상 높이가 달라 단일 등고선 아크로 못 닫는 경우, 매 단계 target에 - 가까워지는 등고선 위 지점 중 |우리 세류 거리 − 인접 세류 거리|가 최소인 곳 - (분수령)으로 이동한다. 레벨 제한 없음(마루는 오르내린다). 막히면 None. - """ - - def _score(point: Point) -> tuple[float, float]: - to_network = network_union.distance(point) - to_other = ( - others[int(other_tree.nearest(point))].distance(point) - if other_tree is not None - else float("inf") - ) - return to_network, to_other - - points: list[Point] = [] - current = start - remaining = current.distance(target) - for _ in range(MARCH_MAX_STEPS): - if remaining <= MARCH_RADIUS_M: - return points - best: tuple[float, Point] | None = None # (|dn-do|, 지점) - for index in contour_index.query(current.buffer(MARCH_RADIUS_M)): - geom = contour_index.geoms[index] - if geom.distance(current) > MARCH_RADIUS_M: - continue - t0 = geom.project(current) - walk = int(MARCH_WALK_M / 10.0) - for offset in [0.0] + [sign * k * 10.0 for k in range(1, walk + 1) for sign in (1, -1)]: - t = min(max(t0 + offset, 0.0), geom.length) - candidate = geom.interpolate(t) - if candidate.distance(current) > MARCH_RADIUS_M: - continue - if candidate.distance(target) > remaining - 5.0: - continue # target에 실질적으로 가까워지는 이동만 허용 - projection = road_line.project(candidate) - if ( - 5.0 < projection < road_line.length - 5.0 - and side_sign(road_line, candidate) == -uphill_sign - ): - continue - to_network, to_other = _score(candidate) - if to_network < STREAM_JOIN_TOL_M: - continue - if to_other < to_network * MARCH_OTHER_RATIO: - continue - balance = abs(to_network - to_other) - if best is None or balance < best[0]: - best = (balance, candidate) - if best is None: - return None - current = best[1] - remaining = current.distance(target) - points.append(current) - return None - - -def trace_upstream_network( - crossing: Point, - stream_features: list[dict[str, Any]], - road_line: LineString, - uphill_sign: int, -) -> tuple[list[LineString], float]: - """세류 교차점에서 도로 산측으로 뻗는 상류망을 추적한다. - - ① 교차한 세류를 교차점에서 잘라 산측 조각을 뿌리로 삼는다. - ② 끝점이 기존 망에 근접(STREAM_JOIN_TOL_M)한 세류를 반복 편입한다(분기 포함). - 도로를 다시 가로지르는 조각은 절단하고, 총연장 상한을 두어 폭주를 막는다. - 반환: (상류망 폴리라인 목록, 최장 유하 경로 길이 m). - """ - lines = _explode_lines(stream_features) - network: list[tuple[LineString, float]] = [] # (폴리라인, 뿌리에서 시작점까지 누적거리) - used: set[int] = set() - total = 0.0 - - # ① 뿌리: 교차점을 지나는 세류의 산측 조각. 도엽 세류는 교차점 부근에서 별도 - # 피처로 조각나 있는 경우가 많아, 근접(STREAM_JOIN_TOL_M) 조각도 뿌리로 받는다. - for index, line in enumerate(lines): - distance = line.distance(crossing) - if distance > STREAM_JOIN_TOL_M: - continue - used.add(index) - if distance <= 1.0: - t = line.project(crossing) - pieces = [substring(line, 0.0, t), substring(line, t, line.length)] - else: - pieces = [line] - for piece in pieces: - if piece.geom_type != "LineString" or piece.length < 1.0: - continue - oriented = _oriented_from(piece, crossing) - probe = oriented.interpolate(min(10.0, oriented.length)) - if side_sign(road_line, probe) != uphill_sign: - continue - clipped = _clip_uphill(oriented, road_line, crossing) - if clipped is None: - continue - network.append((clipped, 0.0)) - total += clipped.length - if not network: - return [], 0.0 - - # ② 편입 반복: 끝점이 망에 닿는 세류를 상류로 붙인다. - grew = True - while grew and total < MAX_UPSTREAM_TOTAL_M: - grew = False - for index, line in enumerate(lines): - if index in used: - continue - attach: tuple[float, Point, LineString, float] | None = None - for endpoint in (Point(line.coords[0]), Point(line.coords[-1])): - for parent, parent_cum in network: - distance = parent.distance(endpoint) - if distance > STREAM_JOIN_TOL_M: - continue - cum = parent_cum + parent.project(endpoint) - if attach is None or distance < attach[0]: - attach = (distance, endpoint, parent, cum) - if attach is None: - continue - used.add(index) - _, endpoint, _, cum = attach - # 좌우(산측) 판정은 도로 근처에서만 신뢰 — 노선에서 먼 상류는 투영 기준이 - # 뒤틀려 부호가 뒤집히므로 연결성과 도로 재교차 절단만으로 판단한다. - near_road = road_line.distance(endpoint) < 2.0 * STREAM_JOIN_TOL_M - if near_road and side_sign(road_line, line.interpolate(0.5, normalized=True)) == ( - -uphill_sign - ): - continue - oriented = _oriented_from(line, endpoint) - clipped = _clip_uphill(oriented, road_line, endpoint) - if clipped is None: - continue - network.append((clipped, cum)) - total += clipped.length - grew = True - - flow_length = max((cum + line.length for line, cum in network), default=0.0) - return [line for line, _ in network], flow_length diff --git a/B05_wf2_Route/_legacy_watershed/README.md b/B05_wf2_Route/_legacy_watershed/README.md deleted file mode 100644 index 7eab5969..00000000 --- a/B05_wf2_Route/_legacy_watershed/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# _legacy_watershed (보관용, 실행 경로 아님) - -2026-07-31 배수유역 전면 재설계로 폐기된 **등고선 아크 추적 + 능선 행진** 방식 엔진 4종이다. -능선/계곡 분리가 안정적이지 않아 격자 흐름(D8 + 상류 BFS) 방식으로 교체되었다. - -| 파일 | 폐기 당시 역할 | -|---|---| -| `B05_wf2_Route_Engine_Drainage_Watershed.py` | 유역 산정 오케스트레이터 (`build_watershed_basins`) | -| `B05_wf2_Route_Engine_Watershed_Trace.py` | 등고선 아크 인덱싱·분수계 행진 | -| `B05_wf2_Route_Engine_Watershed_Assemble.py` | 아크+능선+도로선 폐합 폴리곤 조립 | -| `B05_wf2_Route_Engine_Watershed_Subdivide.py` | 메인 유역 내부 세부유역 분할 | - -**주의** -- 내용은 이동 당시 그대로이며 수정하지 않는다. 서로를 `B05_wf2_Route.B05_wf2_Route_Engine_Watershed_*` - 경로로 import하므로 이 폴더에서는 그대로 실행되지 않는다(의도된 상태 — 참고용 보관). -- 현행 엔진: `B05_wf2_Route_Engine_Watershed_Grid.py` / `_Flow.py` / `_Basin.py`. diff --git a/common_util/common_util_route_geometry.py b/common_util/common_util_route_geometry.py new file mode 100644 index 00000000..22913bcd --- /dev/null +++ b/common_util/common_util_route_geometry.py @@ -0,0 +1,255 @@ +"""계획 노선 기하 공용 유틸 — 정점·누가거리·세류 교차점. + +배수유역 분석(B04)과 관 편집·세부유역(B05)이 같은 노선 표현을 써야 하므로 여기 한 곳에만 +정의한다. 어느 한쪽 페이지 폴더에 두면 반대 방향 import가 생긴다. + +노선 원천은 두 가지다. + · B03에 업로드된 **계획 노선 파일**(CSV) — 배수유역 분석의 입력 + · DB `route_points` — B05에서 탐색·확정한 노선 +둘 다 같은 `RouteVertex` 목록으로 바꿔 아래 함수들이 그대로 받는다. +""" + +from __future__ import annotations + +import csv +import logging +import math +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from shapely.geometry import LineString, Point, shape + +logger = logging.getLogger(__name__) + +# 계획 노선 CSV 열 이름 후보. B03이 여러 형식을 받게 되므로 흔한 표기를 모두 받아 준다. +_X_KEYS = ("x", "X", "동", "easting", "EASTING") +_Y_KEYS = ("y", "Y", "북", "northing", "NORTHING") +_Z_KEYS = ("z", "Z", "표고", "elevation", "ELEV") +_ORDER_KEYS = ("sequence", "order", "seq", "no", "번호") +_EPSG_KEYS = ("crs_epsg", "epsg", "EPSG") + + +@dataclass +class RouteVertex: + """노선 폴리라인의 한 점. chainage는 시점 기준 누가거리(m).""" + + x: float + y: float + z: float + chainage_m: float + + +@dataclass +class StructureCandidate: + """관 매설 구조물 측점 후보.""" + + chainage_m: float + x: float + y: float + # "stream"=세류 교차, "spacing"=최대 간격 규칙 보충, "confirmed"=사용자 확정 + reason: str + stream_name: str | None = None + + +@dataclass +class PlannedRoute: + """계획 노선 파일에서 읽은 노선.""" + + vertices: list[RouteVertex] + epsg: int | None + name: str | None + source: Path + + @property + def line(self) -> LineString: + return LineString([(vertex.x, vertex.y) for vertex in self.vertices]) + + +def read_planned_route_csv(path: Path) -> PlannedRoute | None: + """계획 노선 CSV를 읽어 정점 목록으로 바꾼다. + + 열 이름은 대소문자·한글 표기를 함께 받아 준다(B03이 여러 형식을 수용할 예정). + `sequence`가 있으면 그 순서로 정렬하고, 없으면 파일에 적힌 순서를 그대로 쓴다. + """ + try: + with path.open("r", encoding="utf-8-sig", newline="") as file: + rows = list(csv.DictReader(file)) + except (OSError, csv.Error, UnicodeDecodeError): + logger.warning("계획 노선 CSV를 읽지 못했습니다: %s", path) + return None + if not rows: + return None + + epsg = _first_int(rows[0], _EPSG_KEYS) + name = _first_text(rows[0], ("route_name", "name", "노선명")) + parsed: list[tuple[float, float, float, float]] = [] # (정렬키, x, y, z) + for index, row in enumerate(rows): + x = _first_float(row, _X_KEYS) + y = _first_float(row, _Y_KEYS) + if x is None or y is None: + continue + order = _first_float(row, _ORDER_KEYS) + parsed.append( + (float(index) if order is None else order, x, y, _first_float(row, _Z_KEYS) or 0.0) + ) + if len(parsed) < 2: + logger.warning("계획 노선 CSV에 좌표가 2점 미만입니다: %s", path) + return None + + parsed.sort(key=lambda item: item[0]) + vertices: list[RouteVertex] = [] + cumulative = 0.0 + previous: tuple[float, float] | None = None + for _, x, y, z in parsed: + 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) + logger.info( + "계획 노선 %s: 정점 %d개, 연장 %.0fm, EPSG %s", path.name, len(vertices), cumulative, epsg + ) + return PlannedRoute(vertices=vertices, epsg=epsg, name=name, source=path) + + +def find_planned_route_file(input_dir: Path) -> Path | None: + """B03 입력 폴더에서 계획 노선 파일을 찾는다. 여러 개면 가장 최근 것.""" + 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 + + +def build_route_vertices(points: list[dict[str, Any]]) -> list[RouteVertex]: + """DB route_points 행을 누가거리가 채워진 정점 목록으로 바꾼다.""" + vertices: list[RouteVertex] = [] + cumulative = 0.0 + previous: tuple[float, float] | None = None + for row in points: + x = float(row["x"]) + y = float(row["y"]) + z = float(row.get("z") or 0.0) + if previous is not None: + cumulative += math.dist(previous, (x, y)) + chainage = row.get("chainage_m") + vertices.append( + RouteVertex( + x=x, y=y, z=z, chainage_m=float(chainage) if chainage is not None else cumulative + ) + ) + previous = (x, y) + return vertices + + +def interpolate_vertex( + vertices: list[RouteVertex], chainage_m: float +) -> tuple[float, float, float]: + """누가거리 위치의 (x, y, z)를 선형 보간한다. 범위 밖은 끝점으로 당긴다.""" + if not vertices: + return (0.0, 0.0, 0.0) + if chainage_m <= vertices[0].chainage_m: + return (vertices[0].x, vertices[0].y, vertices[0].z) + for previous, current in zip(vertices, vertices[1:]): + if chainage_m <= current.chainage_m: + span = current.chainage_m - previous.chainage_m + ratio = 0.0 if span <= 0 else (chainage_m - previous.chainage_m) / span + return ( + previous.x + (current.x - previous.x) * ratio, + previous.y + (current.y - previous.y) * ratio, + previous.z + (current.z - previous.z) * ratio, + ) + last = vertices[-1] + return (last.x, last.y, last.z) + + +def is_uphill_at(vertices: list[RouteVertex], chainage_m: float, window_m: float = 20.0) -> bool: + """해당 위치가 오르막(절토부)인지 종단 계획선의 국소 기울기 부호로 판정한다.""" + _, _, back_z = interpolate_vertex(vertices, max(0.0, chainage_m - window_m)) + _, _, forward_z = interpolate_vertex(vertices, chainage_m + window_m) + return forward_z >= back_z + + +def find_stream_crossings( + vertices: list[RouteVertex], + stream_features: list[dict[str, Any]], +) -> list[StructureCandidate]: + """노선 평면 선형과 세류선의 교차 지점을 누가거리 순으로 찾는다.""" + if len(vertices) < 2: + return [] + route_line = LineString([(vertex.x, vertex.y) for vertex in vertices]) + candidates: list[StructureCandidate] = [] + for feature in stream_features: + geometry = feature.get("geometry") + if not geometry: + continue + try: + stream = shape(geometry) + except Exception: # noqa: BLE001 - 손상된 피처는 건너뛴다 + continue + if stream.is_empty: + continue + intersection = route_line.intersection(stream) + if intersection.is_empty: + continue + name = _stream_name(feature) + for point in _collect_points(intersection): + candidates.append( + StructureCandidate( + chainage_m=route_line.project(point), + x=point.x, + y=point.y, + reason="stream", + stream_name=name, + ) + ) + candidates.sort(key=lambda item: item.chainage_m) + return candidates + + +def _stream_name(feature: dict[str, Any]) -> str | None: + properties = feature.get("properties") or {} + for key in ("명칭", "하천명", "NAME", "name"): + value = properties.get(key) + if value: + return str(value) + return None + + +def _collect_points(geometry: Any) -> list[Point]: + """교차 결과(Point/MultiPoint/LineString 등)에서 대표 점들을 뽑는다.""" + if geometry.geom_type == "Point": + return [geometry] + if geometry.geom_type in {"MultiPoint", "GeometryCollection"}: + points: list[Point] = [] + for part in geometry.geoms: + points.extend(_collect_points(part)) + return points + # 선분끼리 겹쳐 선으로 나온 경우는 중점을 대표로 쓴다. + if geometry.geom_type in {"LineString", "MultiLineString"}: + return [geometry.interpolate(0.5, normalized=True)] + return [] + + +def _first_text(row: dict[str, Any], keys: tuple[str, ...]) -> str | None: + for key in keys: + value = row.get(key) + if value not in (None, ""): + return str(value).strip() + return None + + +def _first_float(row: dict[str, Any], keys: tuple[str, ...]) -> float | None: + text = _first_text(row, keys) + if text is None: + return None + try: + return float(text) + except ValueError: + return None + + +def _first_int(row: dict[str, Any], keys: tuple[str, ...]) -> int | None: + value = _first_float(row, keys) + return None if value is None else int(value) diff --git a/config/config_frontend.ts b/config/config_frontend.ts index 3e0f6e56..55a4ddde 100644 --- a/config/config_frontend.ts +++ b/config/config_frontend.ts @@ -41,8 +41,8 @@ export const PROGRESS_UPDATE_INTERVAL_MS = 10_000; /** B03 업로드 Service Worker 번들 경로 */ export const SERVICE_WORKER_PATH = "/assets/B03_FileInput_ServiceWorker.js"; -/** 허용 확장자 (지형/포인트클라우드/도면) */ -export const UPLOAD_ALLOWED_EXT = [".las", ".laz", ".tif", ".tfw", ".prj", ".dxf"] as const; +/** 허용 확장자 (계획노선/지형/포인트클라우드/도면) */ +export const UPLOAD_ALLOWED_EXT = [".csv", ".las", ".laz", ".tif", ".tfw", ".prj", ".dxf"] as const; /* ----------------------------------------------------------------------------- * 3. WebCAD / 3D 렌더링 옵션 diff --git a/config/config_system.py b/config/config_system.py index 51c5e2fc..771fedcc 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -48,7 +48,7 @@ DB_POOL_MAX = int(os.getenv("DB_POOL_MAX", "20")) UPLOAD_MAX_MB = int(os.getenv("UPLOAD_MAX_MB", str(30 * 1024))) UPLOAD_MAX_FILES = int(os.getenv("UPLOAD_MAX_FILES", "5")) UPLOAD_CHUNK_SIZE_BYTES = int(os.getenv("UPLOAD_CHUNK_SIZE_BYTES", str(1024 * 1024 * 1024))) -UPLOAD_ALLOWED_EXT = [".las", ".laz", ".tif", ".tfw", ".prj", ".dxf"] +UPLOAD_ALLOWED_EXT = [".csv", ".las", ".laz", ".tif", ".tfw", ".prj", ".dxf"] CHUNK_TEMP_DIR = os.getenv("CHUNK_TEMP_DIR", "B03_FileInput/chunks_temp") CHUNK_RETENTION_HOURS = int(os.getenv("CHUNK_RETENTION_HOURS", "24")) MERGE_TIMEOUT_SECONDS = int(os.getenv("MERGE_TIMEOUT_SECONDS", "3600")) diff --git a/main.py b/main.py index 50680a2a..0f491da1 100644 --- a/main.py +++ b/main.py @@ -31,6 +31,7 @@ from B04_wf1_Surface.B04_wf1_Surface_Router import router as b04_surface_router from B04_wf1_Surface.B04_wf1_Surface_Router_Contour import router as b04_surface_contour_router from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import router as b04_surface_gis_router from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import tiles_router +from B04_wf1_Surface.B04_wf1_Surface_Router_Watershed import router as b04_watershed_router from B05_wf2_Route.B05_wf2_Route_Router import router as b05_route_router from B05_wf2_Route.B05_wf2_Route_Router_Drainage import router as b05_drainage_router from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Router import router as b06_section_router @@ -273,6 +274,7 @@ app.include_router(b03_file_input_router, dependencies=protected_with_company) app.include_router(b04_surface_router, dependencies=protected_with_company) app.include_router(b04_surface_contour_router, dependencies=protected_with_company) app.include_router(b04_surface_gis_router, dependencies=protected_with_company) +app.include_router(b04_watershed_router, dependencies=protected_with_company) app.include_router(tiles_router, dependencies=protected_with_company) app.include_router(b05_route_router, dependencies=protected_with_company) app.include_router(b05_drainage_router, dependencies=protected_with_company) diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 952e6a17..8061fda4 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -527,13 +527,13 @@ export const ui_locales = { /* --- B03_FileInput 파일 입력 --- */ B03_File_Title: ["파일 입력", "File Input"], B03_File_Subtitle: [ - "지형·포인트클라우드·도면 파일을 업로드하세요.", - "Upload terrain, point cloud, and drawing files.", + "필수 계획노선과 지형·포인트클라우드 파일을 업로드하세요.", + "Upload the required planned route, terrain, and point cloud files.", ], B03_File_Select_Label: ["입력 파일 선택", "Select input files"], B03_File_Select_Hint: [ - "LAS/LAZ 1개를 포함해 관련 PRJ, TFW, TIF 또는 도면 파일을 선택하세요.", - "Select exactly one LAS/LAZ file with related PRJ, TFW, TIF, or drawing files.", + "계획노선 CSV, LAS/LAZ 1개, PRJ, TFW를 선택하세요. TIF는 선택 사항입니다.", + "Select a planned-route CSV, one LAS/LAZ, PRJ, and TFW. TIF is optional.", ], B03_File_Selected_Title: ["선택한 파일", "Selected files"], B03_File_Selected_Empty: ["선택한 파일이 없습니다.", "No files selected."], @@ -566,6 +566,9 @@ export const ui_locales = { B03_File_Result_Path: ["저장 경로", "Stored path"], B03_File_Group_Required: ["필수 파일", "Required files"], B03_File_Group_Optional: ["선택 파일", "Optional files"], + B03_File_Group_Route: ["원청 계획노선 (필수)", "Client Planned Route (Required)"], + B03_File_Group_Terrain: ["지형 분석자료", "Terrain Analysis Files"], + B03_File_Slot_PlannedRoute: ["계획노선 좌표", "Planned Route Coordinates"], B03_File_Slot_PointCloud: ["포인트클라우드", "Point Cloud"], B03_File_Slot_Projection: ["좌표계 정의", "Projection"], B03_File_Slot_RasterCoord: ["래스터 좌표", "Raster Coord 1"], @@ -578,8 +581,8 @@ export const ui_locales = { "A file for this slot is already selected.", ], B03_File_Error_RequiredSlots: [ - "필수 파일(LAS/LAZ, PRJ, TFW)을 모두 선택하세요.", - "Select all required files: LAS/LAZ, PRJ, and TFW.", + "필수 파일(계획노선 CSV, LAS/LAZ, PRJ, TFW)을 모두 선택하세요.", + "Select all required files: planned-route CSV, LAS/LAZ, PRJ, and TFW.", ], B03_File_Error_SlotType: [ "선택한 파일 유형이 이 카드와 맞지 않습니다.",