refactor(B04/B05): 배수유역 분석을 B04로 이관, B05는 저장분 소비만

분석이 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 20:57:09 +09:00
co-authored by Claude Fable 5
parent b0747e74b6
commit 8a0d640e0e
38 changed files with 2440 additions and 3560 deletions
+6 -1
View File
@@ -120,12 +120,17 @@ export async function finalizeUploadSession(
projectId: string,
sessionId: string,
totalChunks: number,
completeUpload: boolean,
): Promise<FileUploadResponse> {
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<FileUploadResponse>(response);
}
@@ -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":
+25
View File
@@ -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,
+92 -15
View File
@@ -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}",
+1
View File
@@ -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):
+11 -5
View File
@@ -368,6 +368,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
async function uploadOneFile(
projectId: string,
state: FileSlotState,
completeUpload: boolean,
): Promise<UploadedFileResult[]> {
const file = state.file;
if (!file) return [];
@@ -421,7 +422,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
}
}
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<void> {
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<void> {
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)
+9
View File
@@ -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;
+8 -1
View File
@@ -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",
@@ -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()
@@ -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()
+116 -4
View File
@@ -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<T>(path: string, init: RequestInit): Promise<T> {
/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환.
*
* `timeoutMs`를 주면 그 값으로 끊는다. 배수유역 격자 해석처럼 수십 초가 걸리는 요청은
* `API_ANALYSIS_TIMEOUT_MS`를 넘긴다 — 기본값으로 두면 계산 도중 abort 된다. */
async function requestJson<T>(
path: string,
init: RequestInit,
timeoutMs: number = API_TIMEOUT_MS,
): Promise<T> {
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<T>(path: string, init: RequestInit): Promise<T> {
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<Array<[number, number]>>;
/** 교차했으나 하류로 판정해 제외한 조각. 판정이 맞는지 눈으로 대조하는 용도. */
downstream_lines: Array<Array<[number, number]>>;
/** 상·하류 어느 망에도 이어지지 않아 제외한 세류 조각 수. */
no_contact_count: number;
/** 노선이 1차 영역 밖으로 나간 길이(m). 크면 반경을 올려야 한다는 신호. */
road_outside_m: number;
/** 1차 영역(상류 세류망 버퍼 합집합)의 외곽 링 목록. */
region_rings: Array<Array<[number, number]>>;
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<WatershedAnalysis> {
// 등고선 하강 방향 + 적색 확장 루프까지 도는 요청이라 수십 초가 걸린다.
return requestJson<WatershedAnalysis>(
`/projects/${projectId}/drainage/primary-region`,
{ method: "GET" },
API_ANALYSIS_TIMEOUT_MS,
);
}
@@ -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
]
@@ -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,
@@ -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,
@@ -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(
@@ -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,
@@ -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,
@@ -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]
@@ -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<GisLayer, PreparedLayer>();
const activeBackgrounds = new Set<BackgroundLayer>(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();
},
@@ -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<readonly [number, number]>,
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<void> {
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);
},
};
}
+4 -96
View File
@@ -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<Array<[number, number]>>;
/** 교차했으나 하류로 판정해 제외한 조각. 판정이 맞는지 눈으로 대조하는 용도. */
downstream_lines: Array<Array<[number, number]>>;
/** 상·하류 어느 망에도 이어지지 않아 제외한 세류 조각 수. */
no_contact_count: number;
/** 노선이 1차 영역 밖으로 나간 길이(m). 크면 반경을 올려야 한다는 신호. */
road_outside_m: number;
/** 1차 영역(상류 세류망 버퍼 합집합)의 외곽 링 목록. */
region_rings: Array<Array<[number, number]>>;
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<DrainagePrimaryRegion> {
// 등고선 하강 방향 + 적색 확장 루프까지 도는 요청이라 수십 초가 걸린다.
return requestJson<DrainagePrimaryRegion>(
`/projects/${projectId}/drainage/primary-region`,
{ method: "GET" },
API_ANALYSIS_TIMEOUT_MS,
);
}
export async function fetchDrainageCandidates(
projectId: string,
): Promise<DrainageCandidateResponse> {
return requestJson<DrainageCandidateResponse>(`/projects/${projectId}/drainage/candidates`, {
method: "GET",
});
}
/** chainages를 주면 그 위치로 확정 산정하고, 비우면 자동 제안분으로 산정한다. */
export async function fetchDrainageBasins(
projectId: string,
+5 -300
View File
@@ -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,
@@ -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
@@ -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)
+35 -402
View File
@@ -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
],
}
+10 -332
View File
@@ -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<DrainageLayer, string> = {
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<readonly [number, number]>,
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<void> {
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 = "배경도를 불러오는 중…";
@@ -24,8 +24,6 @@ const ADD_SNAP_PX = 14;
export interface PipeEditor {
setContext(meta: VWorldMeta | null, points: ReadonlyArray<RoutePointLike>): void;
setPipes(pipes: ReadonlyArray<PipePoint>): void;
/** 도로 위 흐름 강도 [누가거리 m, 상류 면적 ㎡]. 관 추가 판단 근거로 계획선에 덧그린다. */
setStrength(profile: ReadonlyArray<readonly [number, number]>): void;
pipes(): ReadonlyArray<PipePoint>;
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<readonly [number, number]> = [];
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);
@@ -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
@@ -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
@@ -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)
@@ -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
-16
View File
@@ -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`.
+255
View File
@@ -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)
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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"))
+2
View File
@@ -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)
+9 -6
View File
@@ -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: [
"선택한 파일 유형이 이 카드와 맞지 않습니다.",