diff --git a/A00_Common/b_asset_cache.ts b/A00_Common/b_asset_cache.ts new file mode 100644 index 00000000..2b9cc584 --- /dev/null +++ b/A00_Common/b_asset_cache.ts @@ -0,0 +1,327 @@ +/* ============================================================================= + * 3D 자료 브라우저 보관함 (IndexedDB) + * + * 지표면 3D 파일과 등고선은 한 번 만들면 잘 바뀌지 않는데 용량이 크다. 매번 새로 받으면 + * 페이지를 열 때마다 기다려야 하므로, 받은 것을 브라우저에 저장해 두고 다음부터는 그것을 + * 곧바로 화면에 올린다. 저장본을 쓰는 동시에 뒤에서 서버에 "바뀐 것 있나"만 물어보고, + * 바뀌었으면 새로 받아 갱신한다(서버의 ETag 사용). + * + * 프로젝트가 바뀌면 이전 프로젝트 자료는 지운다 — 다른 프로젝트 데이터가 섞이면 안 된다. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { fetchConfirmedSurface } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; + +const DB_NAME = "aislo-asset-cache"; +// 담아 둔 자료의 형식이나 내용이 바뀌면 이 번호를 올린다 — 올리면 기존 보관분을 통째로 버린다. +// v2: 도엽 표시용 사본(잘라낸 자료)을 철회했다. 그 사본을 담고 있던 브라우저는 비워야 한다. +const DB_VERSION = 2; +const STORE = "assets"; + +export interface CachedAsset { + /** `${projectId}|${url}` */ + key: string; + projectId: string; + url: string; + etag: string | null; + savedAt: number; + body: ArrayBuffer; +} + +let dbPromise: Promise | null = null; + +function openDatabase(): Promise { + if (dbPromise) return dbPromise; + dbPromise = new Promise((resolve) => { + if (!("indexedDB" in window)) { + resolve(null); + return; + } + const request = window.indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + // 번호가 올라가면 옛 보관분은 형식이나 내용이 다를 수 있으므로 통째로 버리고 새로 만든다. + if (db.objectStoreNames.contains(STORE)) db.deleteObjectStore(STORE); + const store = db.createObjectStore(STORE, { keyPath: "key" }); + store.createIndex("projectId", "projectId", { unique: false }); + }; + request.onsuccess = () => resolve(request.result); + // 사생활 보호 모드 등으로 열리지 않으면 보관함 없이 동작한다(항상 새로 받는다). + request.onerror = () => resolve(null); + }); + return dbPromise; +} + +function runTransaction( + mode: IDBTransactionMode, + work: (store: IDBObjectStore) => IDBRequest, +): Promise { + return openDatabase().then( + (db) => + new Promise((resolve) => { + if (!db) { + resolve(null); + return; + } + try { + const transaction = db.transaction(STORE, mode); + const request = work(transaction.objectStore(STORE)); + request.onsuccess = () => resolve(request.result ?? null); + request.onerror = () => resolve(null); + } catch { + resolve(null); + } + }), + ); +} + +const cacheKey = (projectId: string, url: string): string => `${projectId}|${url}`; + +async function readAsset(projectId: string, url: string): Promise { + return (await runTransaction("readonly", (store) => + store.get(cacheKey(projectId, url)), + )) as CachedAsset | null; +} + +async function writeAsset(asset: CachedAsset): Promise { + await runTransaction("readwrite", (store) => store.put(asset) as IDBRequest); +} + +/** 다른 프로젝트 자료를 모두 지운다. B그룹 페이지에 들어올 때 호출한다. */ +export async function purgeOtherProjects(projectId: string): Promise { + const db = await openDatabase(); + if (!db) return; + await new Promise((resolve) => { + try { + const transaction = db.transaction(STORE, "readwrite"); + const store = transaction.objectStore(STORE); + const cursorRequest = store.openCursor(); + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + if (!cursor) return; + const value = cursor.value as CachedAsset; + if (value.projectId !== projectId) cursor.delete(); + cursor.continue(); + }; + transaction.oncomplete = () => resolve(); + transaction.onerror = () => resolve(); + } catch { + resolve(); + } + }); +} + +export interface CachedFetchOptions { + /** 내려받는 동안 진행률(0~1, 모르면 null)을 알려준다. 저장본을 쓰면 호출되지 않는다. */ + onProgress?: (ratio: number | null) => void; +} + +/** 네트워크에서 받아 보관함에 저장한다. */ +async function downloadAndStore( + projectId: string, + url: string, + options: CachedFetchOptions, +): Promise { + const response = await fetch(url, { credentials: "include" }); + if (!response.ok) throw new Error(`요청 실패: ${response.status}`); + const total = Number(response.headers.get("content-length") ?? 0); + const etag = response.headers.get("etag"); + + let body: ArrayBuffer; + if (response.body && options.onProgress) { + // 진행률을 보여 주기 위해 조각으로 읽는다. + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + received += value.length; + options.onProgress(total > 0 ? received / total : null); + } + const merged = new Uint8Array(received); + let offset = 0; + chunks.forEach((chunk) => { + merged.set(chunk, offset); + offset += chunk.length; + }); + body = merged.buffer; + } else { + body = await response.arrayBuffer(); + } + + await writeAsset({ + key: cacheKey(projectId, url), + projectId, + url, + etag, + savedAt: Date.now(), + body, + }); + return body; +} + +/** 저장본이 최신인지 뒤에서 확인하고, 바뀌었으면 새로 받아 저장한다. */ +function revalidateInBackground(projectId: string, url: string, etag: string | null): void { + if (!etag) return; + void fetch(url, { credentials: "include", headers: { "If-None-Match": etag } }) + .then(async (response) => { + if (response.status === 304 || !response.ok) return; + const body = await response.arrayBuffer(); + await writeAsset({ + key: cacheKey(projectId, url), + projectId, + url, + etag: response.headers.get("etag"), + savedAt: Date.now(), + body, + }); + }) + .catch(() => { + /* 오프라인 등으로 확인하지 못해도 저장본을 계속 쓴다. */ + }); +} + +/** 저장본이 있으면 즉시 돌려주고 뒤에서 갱신 확인, 없으면 받아서 저장한 뒤 돌려준다. */ +export async function fetchCachedBytes( + projectId: string, + url: string, + options: CachedFetchOptions = {}, +): Promise { + const cached = await readAsset(projectId, url); + if (cached?.body) { + revalidateInBackground(projectId, url, cached.etag); + return cached.body; + } + return downloadAndStore(projectId, url, options); +} + +/** JSON 자료용. 저장본을 쓰면 파싱만 하고 네트워크를 타지 않는다. */ +export async function fetchCachedJson( + projectId: string, + url: string, + options: CachedFetchOptions = {}, +): Promise { + const bytes = await fetchCachedBytes(projectId, url, options); + return JSON.parse(new TextDecoder().decode(bytes)) as T; +} + +/** 배수유역도 배경으로 쓰는 도엽 레이어(유일한 정의처 — 준비화면과 B05 패널이 함께 쓴다). */ +export const DRAINAGE_SHEET_LAYERS = ["도엽_등고선", "도엽_하천중심선"] as const; + +/** 도엽 레이어(GeoJSON)를 보관함에서 먼저 찾는다. + * + * 서버는 프로젝트 주변만 잘라 좌표 자릿수를 줄인 표시용 사본을 ETag와 함께 내보낸다. + * 분석용 원본과는 별개 파일이므로, 담아 두었다가 그대로 다시 써도 화면이 어긋나지 않는다. */ +export async function fetchCachedSheetLayer(projectId: string, layer: string): Promise { + return fetchCachedJson( + projectId, + `${API_BASE_URL}/projects/${projectId}/geojson?layer=${encodeURIComponent(layer)}`, + ); +} + +/* ── 준비 화면 연동 ──────────────────────────────────────────────────────── + * 무엇을 이미 준비했는지, 준비가 끝나면 어느 화면으로 보낼지를 탭 단위로 기억한다. + * 대시보드로 나갔다가 같은 프로젝트로 돌아오면 준비 화면을 건너뛴다(2026-08-01 사용자 지시). + * + * 표식은 프로젝트 번호가 아니라 **확정 구성(signature)** 이다. 관리자가 B04에서 다른 + * 필터·표현으로 다시 확정하면 표식이 달라져 준비 화면이 한 번 더 돌고 새 자료를 담는다. + * 프로젝트 번호만 봤다면 옛 자료를 계속 쓰게 된다(2026-08-01 사용자 지시). */ +const PRELOADED_SIGNATURE_KEY = "frd_preloaded_signature"; +const PRELOAD_TARGET_KEY = "frd_preload_target"; + +const preloadStamp = (projectId: string, signature: string): string => `${projectId}|${signature}`; + +export function isProjectPreloaded(projectId: string, signature: string): boolean { + try { + return ( + window.sessionStorage.getItem(PRELOADED_SIGNATURE_KEY) === preloadStamp(projectId, signature) + ); + } catch { + return false; + } +} + +export function markProjectPreloaded(projectId: string, signature: string): void { + try { + window.sessionStorage.setItem(PRELOADED_SIGNATURE_KEY, preloadStamp(projectId, signature)); + } catch { + /* 세션 저장 실패는 준비 화면이 한 번 더 뜨는 정도의 영향뿐이다. */ + } +} + +/** 담아 둔 표식을 지운다 — 확정이 바뀌어 자료를 다시 담아야 할 때 호출한다. */ +export function clearPreloadMark(): void { + try { + window.sessionStorage.removeItem(PRELOADED_SIGNATURE_KEY); + } catch { + /* 지우지 못해도 다음 표식 비교에서 불일치로 걸러진다. */ + } +} + +export function setPreloadTarget(route: string): void { + try { + window.sessionStorage.setItem(PRELOAD_TARGET_KEY, route); + } catch { + /* 저장 실패 시 준비 화면이 기본 화면으로 보낸다. */ + } +} + +export function readPreloadTarget(): string | null { + try { + return window.sessionStorage.getItem(PRELOAD_TARGET_KEY); + } catch { + return null; + } +} + +/** 준비 화면이 표시할 단계 안내. ratio가 null이면 진행률을 모른다는 뜻이다. */ +export type PreloadReporter = (label: string, ratio: number | null) => void; + +/** 확정된 지표면의 3D 파일과 그 등고선을 보관함에 채운다(준비 화면에서 호출). + * + * 사용자가 실제로 보는 것은 이 둘이라 이것만 챙긴다 — 포인트클라우드·배수유역은 제외 + * (2026-08-01 사용자 지시). 이미 보관돼 있으면 거의 즉시 끝난다. + * 평활 여부·등고선 간격은 짐작하지 않고 확정 저장값을 그대로 쓴다 — 짐작하면 B04·B05가 + * 서로 다른 파일을 받아 같은 지형을 두 번 내려받게 된다. + * 확정 지표면을 찾지 못하면 오류를 던져 준비 화면이 안내 문구를 띄우게 한다. + * 반환값은 담아 둔 구성의 signature — 호출측이 준비 표식으로 저장한다. */ +export async function preloadSurfaceAssets( + projectId: string, + report: PreloadReporter = () => {}, +): Promise { + report("확정된 지표면을 확인하는 중…", null); + const confirmed = await fetchConfirmedSurface(projectId); + if (!confirmed.model_id) throw new Error("확정된 지표면 모델이 없습니다."); + + const smooth = confirmed.smooth ?? false; + const interval = confirmed.contour_interval_m ?? 1.0; + const base = `${API_BASE_URL}/projects/${projectId}/surface/models/${confirmed.model_id}`; + + report("3D 지표면을 준비하는 중…", 0); + await fetchCachedBytes(projectId, `${base}/preview?smooth=${smooth}`, { + onProgress: (ratio) => report("3D 지표면을 준비하는 중…", ratio), + }); + + report("등고선을 준비하는 중…", null); + await fetchCachedBytes( + projectId, + `${base}/contour?interval=${interval}&smooth=${smooth}&recalculate=false`, + { onProgress: (ratio) => report("등고선을 준비하는 중…", ratio) }, + ); + + // 배수유역도 배경(도엽 표시본·위성사진)도 함께 담는다 — 없으면 B05가 진입할 때마다 받는다. + // 이 자료가 없어도 화면은 뜨므로 실패해도 준비를 멈추지 않는다. + report("배경 지도를 준비하는 중…", null); + await Promise.all( + DRAINAGE_SHEET_LAYERS.map((layer) => fetchCachedSheetLayer(projectId, layer).catch(() => null)), + ); + // 위성사진은 로 표시하므로 보관함이 아니라 브라우저 자체 캐시를 데워 둔다. + await fetch(`${API_BASE_URL}/projects/${projectId}/vworld-map?layer_name=satellite`, { + credentials: "include", + }).catch(() => null); + + report("준비 완료", 1); + return confirmed.signature; +} diff --git a/A00_Common/b_workflow_nav.ts b/A00_Common/b_workflow_nav.ts index fb0ee940..11c843a5 100644 --- a/A00_Common/b_workflow_nav.ts +++ b/A00_Common/b_workflow_nav.ts @@ -5,6 +5,8 @@ import { type RoutePath, } from "@config/config_frontend"; import type { WorkflowStage } from "@ui/ui_template_workflow_layout"; +import { fetchConfirmedSurface } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; +import { isProjectPreloaded, setPreloadTarget } from "./b_asset_cache"; import { navigateTo } from "./router"; export interface WorkflowState { @@ -36,5 +38,28 @@ export async function fetchWorkflowState(projectId: string): Promise { + let signature: string | null = null; + try { + signature = (await fetchConfirmedSurface(projectId)).signature; + } catch { + signature = null; + } + if (signature && isProjectPreloaded(projectId, signature)) { + navigateTo(route); + return; + } + setPreloadTarget(route); + navigateTo(ROUTES.B11_LOADING); } diff --git a/A00_Common/router.ts b/A00_Common/router.ts index 3a2c83ef..df75959b 100644 --- a/A00_Common/router.ts +++ b/A00_Common/router.ts @@ -55,6 +55,8 @@ const routeTable: Partial Promise>> = { (await import("../B10_Payment/B10_Payment_UI_Page")).renderB10Payment, [ROUTES.B11_STATUS]: async () => (await import("../B11_Status/B11_Status_UI_Page")).renderB11Status, + [ROUTES.B11_LOADING]: async () => + (await import("../B11_Status/B11_Status_UI_Loading")).renderB11Loading, }; /** 로그인 여부 (토큰 존재 확인) */ @@ -112,6 +114,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise { ROUTES.B09_WF6_ESTIMATION, ROUTES.B10_PAYMENT, ROUTES.B11_STATUS, + ROUTES.B11_LOADING, ]; if (workflowRoutes.includes(route)) { diff --git a/B03_FileInput/B03_FileInput_Api_Fetch.ts b/B03_FileInput/B03_FileInput_Api_Fetch.ts index 62bab955..e9664935 100644 --- a/B03_FileInput/B03_FileInput_Api_Fetch.ts +++ b/B03_FileInput/B03_FileInput_Api_Fetch.ts @@ -120,12 +120,17 @@ export async function finalizeUploadSession( projectId: string, sessionId: string, totalChunks: number, + completeUpload: boolean, ): Promise { const response = await fetch(`${API_BASE_URL}/projects/${projectId}/finalize`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ session_id: sessionId, total_chunks: totalChunks }), + body: JSON.stringify({ + session_id: sessionId, + total_chunks: totalChunks, + complete_upload: completeUpload, + }), }); return await readJsonOrThrow(response); } diff --git a/B03_FileInput/B03_FileInput_Engine_Analyze.py b/B03_FileInput/B03_FileInput_Engine_Analyze.py index 682b711b..3bed2c34 100644 --- a/B03_FileInput/B03_FileInput_Engine_Analyze.py +++ b/B03_FileInput/B03_FileInput_Engine_Analyze.py @@ -1,5 +1,6 @@ """B03 원본 입력 파일 메타데이터 분석.""" +import csv import logging import math import re @@ -269,10 +270,121 @@ def analyze_tif_metadata(path: str | Path) -> dict[str, Any]: rasterio_logger.removeFilter(warning_filter) +_PLANNED_ROUTE_COLUMNS = ("route_name", "sequence", "x", "y", "z", "crs_epsg") + + +def _parse_route_integer(value: str, *, field: str, row_number: int) -> int: + normalized = value.strip() + if not re.fullmatch(r"[0-9]+", normalized): + raise ValueError(f"CSV {row_number}행의 {field} 값은 양의 정수여야 합니다.") + parsed = int(normalized) + if parsed <= 0: + raise ValueError(f"CSV {row_number}행의 {field} 값은 양의 정수여야 합니다.") + return parsed + + +def _parse_route_coordinate(value: str, *, field: str, row_number: int) -> float: + try: + parsed = float(value.strip()) + except (AttributeError, ValueError) as exc: + raise ValueError(f"CSV {row_number}행의 {field} 값은 숫자여야 합니다.") from exc + if not math.isfinite(parsed): + raise ValueError(f"CSV {row_number}행의 {field} 값은 유한한 숫자여야 합니다.") + return parsed + + +def analyze_planned_route_csv(path: str | Path) -> dict[str, Any]: + """원청 계획노선 CSV를 검증하고 경로 메타데이터를 반환한다.""" + source = Path(path) + with source.open("r", encoding="utf-8-sig", newline="") as csv_file: + reader = csv.DictReader(csv_file) + if reader.fieldnames is None: + raise ValueError("계획노선 CSV 헤더를 찾을 수 없습니다.") + + normalized_headers = [header.strip() for header in reader.fieldnames] + if len(set(normalized_headers)) != len(normalized_headers): + raise ValueError("계획노선 CSV 헤더에 중복된 열이 있습니다.") + header_map = dict(zip(normalized_headers, reader.fieldnames, strict=True)) + missing = [column for column in _PLANNED_ROUTE_COLUMNS if column not in header_map] + if missing: + raise ValueError(f"계획노선 CSV 필수 열이 없습니다: {', '.join(missing)}") + + route_name: str | None = None + crs_epsg: int | None = None + points: list[tuple[float, float, float]] = [] + for expected_sequence, row in enumerate(reader, start=1): + row_number = expected_sequence + 1 + current_name = (row.get(header_map["route_name"]) or "").strip() + if not current_name: + raise ValueError(f"CSV {row_number}행의 route_name 값이 비어 있습니다.") + if route_name is None: + route_name = current_name + elif current_name != route_name: + raise ValueError("계획노선 CSV에는 하나의 route_name만 사용할 수 있습니다.") + + sequence = _parse_route_integer( + row.get(header_map["sequence"]) or "", + field="sequence", + row_number=row_number, + ) + if sequence != expected_sequence: + raise ValueError( + f"CSV {row_number}행의 sequence는 {expected_sequence}이어야 합니다." + ) + + current_epsg = _parse_route_integer( + row.get(header_map["crs_epsg"]) or "", + field="crs_epsg", + row_number=row_number, + ) + if crs_epsg is None: + crs_epsg = current_epsg + elif current_epsg != crs_epsg: + raise ValueError("계획노선 CSV의 crs_epsg는 모든 행에서 같아야 합니다.") + + points.append( + tuple( + _parse_route_coordinate( + row.get(header_map[field]) or "", + field=field, + row_number=row_number, + ) + for field in ("x", "y", "z") + ) + ) + + if len(points) < 2: + raise ValueError("계획노선 CSV에는 좌표가 2개 이상 있어야 합니다.") + + xs, ys, zs = zip(*points, strict=True) + return { + "file": source.name, + "extension": "csv", + "size_bytes": source.stat().st_size, + "purpose": "planned_route", + "route_name": route_name, + "point_count": len(points), + "epsg": crs_epsg, + "columns": list(_PLANNED_ROUTE_COLUMNS), + "bounds": { + "x_min": min(xs), + "x_max": max(xs), + "y_min": min(ys), + "y_max": max(ys), + "z_min": min(zs), + "z_max": max(zs), + }, + "start_point": list(points[0]), + "end_point": list(points[-1]), + } + + def analyze_input_metadata(path: str | Path) -> dict[str, Any]: """입력 파일 확장자에 맞는 B03 메타데이터 분석 함수를 호출한다.""" source = Path(path) extension = source.suffix.lower() + if extension == ".csv": + return analyze_planned_route_csv(source) if extension in {".las", ".laz"}: return analyze_las_metadata(source) if extension == ".prj": diff --git a/B03_FileInput/B03_FileInput_Repository.py b/B03_FileInput/B03_FileInput_Repository.py index 752b1d78..f9ed89a7 100644 --- a/B03_FileInput/B03_FileInput_Repository.py +++ b/B03_FileInput/B03_FileInput_Repository.py @@ -61,6 +61,31 @@ async def create_input_file( return int(input_file_id) +async def get_project_input_readiness( + connection: aiomysql.Connection, + project_id: UUID, +) -> tuple[set[str], int | None]: + """현재 업로드 파일 유형과 최신 포인트클라우드 입력 ID를 반환한다.""" + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """ + SELECT id, LOWER(file_type) AS file_type + FROM input_files + WHERE project_id = %s AND status IN ('UPLOADED', 'PROCESSED') + ORDER BY id DESC + """, + (str(project_id),), + ) + rows = await cursor.fetchall() + + file_types = {str(row["file_type"]) for row in rows if row.get("file_type")} + point_cloud_id = next( + (int(row["id"]) for row in rows if str(row.get("file_type") or "") in {"las", "laz"}), + None, + ) + return file_types, point_cloud_id + + async def get_project_storage_relative_path( connection: aiomysql.Connection, project_id: UUID, diff --git a/B03_FileInput/B03_FileInput_Router.py b/B03_FileInput/B03_FileInput_Router.py index f69d088b..afcbef4b 100644 --- a/B03_FileInput/B03_FileInput_Router.py +++ b/B03_FileInput/B03_FileInput_Router.py @@ -1,6 +1,7 @@ """B03 파일 입력 FastAPI 라우터.""" import asyncio +import json import logging from pathlib import Path from typing import Any @@ -25,6 +26,7 @@ from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata from B03_FileInput.B03_FileInput_Repository import ( create_input_file, create_upload_session, + get_project_input_readiness, get_project_storage_relative_path, get_upload_session, list_completed_chunk_indexes, @@ -60,13 +62,69 @@ from config.config_system import ( logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B03 File Input"]) +_REQUIRED_FILE_TYPES = frozenset({"csv", "prj", "tfw"}) +_POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"}) + def _total_chunks(size_bytes: int, chunk_size_bytes: int) -> int: return max(1, (size_bytes + chunk_size_bytes - 1) // chunk_size_bytes) def _is_point_cloud_result(result: UploadedFileResult) -> bool: - return result.file_type.lower() in {"las", "laz"} + return result.file_type.lower() in _POINT_CLOUD_FILE_TYPES + + +def _missing_required_file_types(file_types: set[str]) -> list[str]: + missing = sorted(_REQUIRED_FILE_TYPES - file_types) + if not file_types.intersection(_POINT_CLOUD_FILE_TYPES): + missing.append("las/laz") + return missing + + +def _require_complete_file_set(file_types: set[str]) -> None: + missing = _missing_required_file_types(file_types) + if missing: + raise ValueError(f"B03 필수 입력 파일이 없습니다: {', '.join(missing)}") + + +async def _complete_file_input_if_ready( + connection: aiomysql.Connection, + project_id: UUID, +) -> int: + file_types, point_cloud_input_id = await get_project_input_readiness(connection, project_id) + _require_complete_file_set(file_types) + if point_cloud_input_id is None: + raise ValueError("LAS 또는 LAZ 입력 파일을 찾을 수 없습니다.") + async with connection.cursor() as cursor: + await complete_stage(cursor, str(project_id), 0) + return point_cloud_input_id + + +def _write_stage_metadata( + stage_root: Path, + project_id: UUID, + results: list[UploadedFileResult], +) -> None: + metadata_path = stage_root / "metadata.json" + existing_files: list[dict[str, Any]] = [] + if metadata_path.exists(): + try: + payload = json.loads(metadata_path.read_text(encoding="utf-8")) + existing_files = list(payload.get("files") or []) + except (OSError, TypeError, ValueError): + logger.warning("B03 metadata.json을 읽지 못해 새로 작성합니다: %s", metadata_path) + + merged = { + str(item.get("relative_path") or item.get("original_filename")): item + for item in existing_files + } + for result in results: + dumped = result.model_dump() + merged[result.relative_path] = dumped + atomic_write_json( + metadata_path, + {"project_id": str(project_id), "files": list(merged.values())}, + ) def _schedule_background_task(coro: Any, *, task_name: str) -> None: @@ -170,11 +228,31 @@ async def upload_project_files( "message": "LAS 또는 LAZ 파일을 정확히 1개 포함해야 합니다.", }, ) + csv_count = sum(Path(filename).suffix.lower() == ".csv" for filename in filenames) + if csv_count != 1: + return JSONResponse( + status_code=400, + content={ + "status": "error", + "message": "계획노선 CSV 파일을 정확히 1개 포함해야 합니다.", + }, + ) + request_file_types = {Path(filename).suffix.lower().lstrip(".") for filename in filenames} + missing_required = _missing_required_file_types(request_file_types) + if missing_required: + return JSONResponse( + status_code=400, + content={ + "status": "error", + "message": f"B03 필수 입력 파일이 없습니다: {', '.join(missing_required)}", + }, + ) pool = get_db_pool() saved_paths: list[Path] = [] try: results: list[UploadedFileResult] = [] + point_cloud_input_id: int | None = None async with pool.acquire() as connection: stored_path = await get_project_storage_relative_path(connection, project_id) @@ -219,18 +297,14 @@ async def upload_project_files( metadata=metadata, ) ) - async with connection.cursor() as cursor: - await complete_stage(cursor, str(project_id), 0) + point_cloud_input_id = await _complete_file_input_if_ready(connection, project_id) await connection.commit() except Exception: await connection.rollback() raise stage_root = project_root / "B03_FileInput" - atomic_write_json( - stage_root / "metadata.json", - {"project_id": str(project_id), "files": [result.model_dump() for result in results]}, - ) + _write_stage_metadata(stage_root, project_id, results) workflow_path = project_root / "workflow.json" if not workflow_path.exists(): atomic_write_json(workflow_path, load_project_workflow(project_root)) @@ -246,10 +320,11 @@ async def upload_project_files( ), task_name=f"b03-upload-email-{project_id}", ) + if point_cloud_input_id is not None: _schedule_background_task( trigger_wf1_analysis_and_email( project_id=project_id, - input_file_id=point_cloud_result.input_file_id, + input_file_id=point_cloud_input_id, user_role=str(session["role"]), ), task_name=f"b04-wf1-auto-{project_id}", @@ -390,6 +465,7 @@ async def finalize_project_upload( """청크 업로드를 최종 병합하고 input_files 메타데이터를 기록한다.""" pool = get_db_pool() final_path: Path | None = None + point_cloud_input_id: int | None = None try: async with pool.acquire() as connection: session = await get_upload_session( @@ -444,8 +520,11 @@ async def finalize_project_upload( metadata=metadata, ) await mark_upload_session_completed(connection, session_id=payload.session_id) - async with connection.cursor() as cursor: - await complete_stage(cursor, str(project_id), 0) + if payload.complete_upload: + point_cloud_input_id = await _complete_file_input_if_ready( + connection, + project_id, + ) await connection.commit() except Exception: await connection.rollback() @@ -462,10 +541,7 @@ async def finalize_project_upload( metadata=metadata, ) stage_root = project_root / "B03_FileInput" - atomic_write_json( - stage_root / "metadata.json", - {"project_id": str(project_id), "files": [result.model_dump()]}, - ) + _write_stage_metadata(stage_root, project_id, [result]) if _is_point_cloud_result(result): _schedule_background_task( _send_upload_complete_notification( @@ -474,10 +550,11 @@ async def finalize_project_upload( ), task_name=f"b03-upload-email-{project_id}", ) + if point_cloud_input_id is not None: _schedule_background_task( trigger_wf1_analysis_and_email( project_id=project_id, - input_file_id=result.input_file_id, + input_file_id=point_cloud_input_id, user_role=str(session["role"]), ), task_name=f"b04-wf1-auto-{project_id}", diff --git a/B03_FileInput/B03_FileInput_Schema.py b/B03_FileInput/B03_FileInput_Schema.py index 32247939..e1338472 100644 --- a/B03_FileInput/B03_FileInput_Schema.py +++ b/B03_FileInput/B03_FileInput_Schema.py @@ -89,6 +89,7 @@ class UploadFinalizeRequest(BaseModel): session_id: str = Field(min_length=1, max_length=36) total_chunks: int = Field(gt=0) + complete_upload: bool = True class UploadStatusResponse(BaseModel): diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index 7b618c41..140360eb 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -368,6 +368,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { async function uploadOneFile( projectId: string, state: FileSlotState, + completeUpload: boolean, ): Promise { const file = state.file; if (!file) return []; @@ -421,7 +422,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { } } - const response = await finalizeUploadSession(projectId, session, totalChunks); + const response = await finalizeUploadSession(projectId, session, totalChunks, completeUpload); localStorage.removeItem(storageKey); saveB03UploadedFile(projectId, { slot: state.slot, @@ -470,8 +471,11 @@ export async function renderB03FileInput(root: HTMLElement): Promise { pageError.textContent = ""; const uploaded: UploadedFileResult[] = []; try { - for (const state of targetStates) { - uploaded.push(...(await uploadOneFile(activeProjectId, state))); + for (let index = 0; index < targetStates.length; index += 1) { + const state = targetStates[index]; + uploaded.push( + ...(await uploadOneFile(activeProjectId, state, index === targetStates.length - 1)), + ); } renderUploadResults(uploaded); showToast(L("B03_File_Upload_Success"), "success"); @@ -544,11 +548,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise { uploadControlPanel.className = "b03-file__control-panel"; uploadControlPanel.append(subtitle, dropzone, resumeBanner, pageError, uploadButton, resultList); - const filesGroup = createCardGroup("", ["las_laz", "prj", "tfw", "tif"]); // 타이틀 공백으로 전달 + const routeGroup = createCardGroup(L("B03_File_Group_Route"), ["csv"]); + routeGroup.classList.add("b03-file__group--route"); + const filesGroup = createCardGroup(L("B03_File_Group_Terrain"), ["las_laz", "prj", "tfw", "tif"]); const cardsContainer = document.createElement("div"); cardsContainer.className = "b03-file__control-panel b03-file__cards-container-panel"; - cardsContainer.append(filesGroup); + cardsContainer.append(routeGroup, filesGroup); const workflowState = activeProjectId ? await fetchWorkflowState(activeProjectId).catch(() => undefined) diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index 2726521f..9ac5af96 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -136,6 +136,15 @@ gap: var(--spacing-24); } +.b03-file__group--route .b03-file__group-content { + grid-template-columns: 1fr; +} + +.b03-file__group--route .b03-file__card { + border-color: var(--color-royal-amethyst, #3e0079); + background: var(--color-mist-violet, #edecff); +} + /* Wiza 8px radius 카드 */ .b03-file__card { min-height: 220px; diff --git a/B03_FileInput/B03_FileInput_UI_Support.ts b/B03_FileInput/B03_FileInput_UI_Support.ts index 40daa173..ad2a509c 100644 --- a/B03_FileInput/B03_FileInput_UI_Support.ts +++ b/B03_FileInput/B03_FileInput_UI_Support.ts @@ -1,6 +1,6 @@ import { ui_locales } from "@ui/ui_template_locale"; -export type FileSlot = "las_laz" | "prj" | "tfw" | "tif" | "dxf"; +export type FileSlot = "csv" | "las_laz" | "prj" | "tfw" | "tif" | "dxf"; export type UploadStatus = "pending" | "uploading" | "completed" | "failed"; export interface SlotConfig { @@ -35,6 +35,13 @@ export interface StoredUploadSession { } const SLOT_CONFIGS: readonly SlotConfig[] = [ + { + slot: "csv", + labelKey: "B03_File_Slot_PlannedRoute", + icon: "⌁", + extensions: [".csv"], + isRequired: true, + }, { slot: "las_laz", labelKey: "B03_File_Slot_PointCloud", diff --git a/B03_FileInput/test_B03_FileInput_Engine_Analyze.py b/B03_FileInput/test_B03_FileInput_Engine_Analyze.py new file mode 100644 index 00000000..39dfa9d3 --- /dev/null +++ b/B03_FileInput/test_B03_FileInput_Engine_Analyze.py @@ -0,0 +1,58 @@ +import tempfile +import unittest +from pathlib import Path + +from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_planned_route_csv + + +class PlannedRouteCsvTest(unittest.TestCase): + def analyze(self, content: str) -> dict: + with tempfile.TemporaryDirectory() as temporary_dir: + path = Path(temporary_dir) / "planned_route.csv" + path.write_text(content, encoding="utf-8") + return analyze_planned_route_csv(path) + + def test_valid_route_returns_metadata(self) -> None: + metadata = self.analyze( + "route_name,sequence,x,y,z,crs_epsg\n" + "sample,1,183493.5,489290.335,544.659,5187\n" + "sample,2,183500.0,489300.0,545.0,5187\n" + ) + + self.assertEqual(metadata["purpose"], "planned_route") + self.assertEqual(metadata["route_name"], "sample") + self.assertEqual(metadata["point_count"], 2) + self.assertEqual(metadata["epsg"], 5187) + self.assertEqual(metadata["start_point"], [183493.5, 489290.335, 544.659]) + + def test_missing_header_is_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "필수 열"): + self.analyze("route_name,sequence,x,y,crs_epsg\nsample,1,183493.5,489290.335,5187\n") + + def test_non_numeric_coordinate_is_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "x 값은 숫자"): + self.analyze( + "route_name,sequence,x,y,z,crs_epsg\n" + "sample,1,not-a-number,489290.335,544.659,5187\n" + "sample,2,183500.0,489300.0,545.0,5187\n" + ) + + def test_non_contiguous_sequence_is_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "sequence는 2"): + self.analyze( + "route_name,sequence,x,y,z,crs_epsg\n" + "sample,1,183493.5,489290.335,544.659,5187\n" + "sample,3,183500.0,489300.0,545.0,5187\n" + ) + + def test_mixed_epsg_is_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "모든 행에서 같아야"): + self.analyze( + "route_name,sequence,x,y,z,crs_epsg\n" + "sample,1,183493.5,489290.335,544.659,5187\n" + "sample,2,183500.0,489300.0,545.0,5186\n" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/B03_FileInput/test_B03_FileInput_Router.py b/B03_FileInput/test_B03_FileInput_Router.py new file mode 100644 index 00000000..84f7671b --- /dev/null +++ b/B03_FileInput/test_B03_FileInput_Router.py @@ -0,0 +1,60 @@ +import json +import tempfile +import unittest +from pathlib import Path +from uuid import UUID + +from B03_FileInput.B03_FileInput_Router import ( + _missing_required_file_types, + _write_stage_metadata, +) +from B03_FileInput.B03_FileInput_Schema import UploadedFileResult + + +class B03RouterHelperTest(unittest.TestCase): + def test_required_file_types_include_planned_route(self) -> None: + self.assertEqual( + _missing_required_file_types({"las", "prj", "tfw"}), + ["csv"], + ) + self.assertEqual( + _missing_required_file_types({"csv", "laz", "prj", "tfw"}), + [], + ) + + def test_stage_metadata_preserves_existing_files(self) -> None: + project_id = UUID("acb9170b-9ac8-49b3-82a0-51cfa32bb42d") + with tempfile.TemporaryDirectory() as temporary_dir: + stage_root = Path(temporary_dir) + (stage_root / "metadata.json").write_text( + json.dumps( + { + "project_id": str(project_id), + "files": [ + { + "original_filename": "terrain.las", + "relative_path": "B03_FileInput/input/las/terrain.las", + } + ], + } + ), + encoding="utf-8", + ) + route = UploadedFileResult( + input_file_id=100, + original_filename="planned_route.csv", + file_type="csv", + relative_path="B03_FileInput/input/csv/planned_route.csv", + size_bytes=1000, + metadata={"purpose": "planned_route", "epsg": 5187}, + ) + + _write_stage_metadata(stage_root, project_id, [route]) + + payload = json.loads((stage_root / "metadata.json").read_text(encoding="utf-8")) + self.assertEqual(len(payload["files"]), 2) + self.assertEqual(payload["files"][1]["metadata"]["purpose"], "planned_route") + + +if __name__ == "__main__": + unittest.main() diff --git a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts index edcd77f8..52037edb 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts @@ -11,7 +11,7 @@ * - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환. * ========================================================================== */ -import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; +import { API_ANALYSIS_TIMEOUT_MS, API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; /** 지표면 분석 실행 요청 (SurfaceAnalyzeRequest) */ export interface SurfaceAnalyzeRequest { @@ -111,10 +111,42 @@ export interface SurfaceModelListResponse { models: SurfaceModelSummary[]; } -/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */ -async function requestJson(path: string, init: RequestInit): Promise { +/** 확정 지표면 요약 (SurfaceConfirmedResponse). + * 포인트 배열 없이 확정 구성과 지형 가장자리만 담는다 — 진입 판정·준비화면·B05 공용. */ +export interface SurfaceConfirmedResponse { + status: string; + project_id: string; + model_id: number | null; + source_filter: string | null; + method: string | null; + smooth: boolean | null; + contour_interval_m: number | null; + /** 확정 구성이 바뀌었는지 한 줄로 비교하기 위한 값. */ + signature: string; + point_count: number | null; + bounds: { + x_min: number; + x_max: number; + y_min: number; + y_max: number; + z_min: number; + z_max: number; + } | null; + /** 계획노선(B03 CSV)의 평면 범위. 지도 초기 화면을 도로 중심으로 맞출 때 쓴다. */ + route_bounds: { x_min: number; x_max: number; y_min: number; y_max: number } | null; +} + +/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. + * + * `timeoutMs`를 주면 그 값으로 끊는다. 배수유역 격자 해석처럼 수십 초가 걸리는 요청은 + * `API_ANALYSIS_TIMEOUT_MS`를 넘긴다 — 기본값으로 두면 계산 도중 abort 된다. */ +async function requestJson( + path: string, + init: RequestInit, + timeoutMs: number = API_TIMEOUT_MS, +): Promise { const controller = new AbortController(); - const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); + const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(`${API_BASE_URL}${path}`, { ...init, @@ -130,6 +162,12 @@ async function requestJson(path: string, init: RequestInit): Promise { throw new Error(payload.message ?? `HTTP ${response.status}`); } return payload; + } catch (error) { + // AbortError 원문("signal is aborted without reason")은 원인을 알 수 없으니 바꿔 준다. + if (error instanceof DOMException && error.name === "AbortError") { + throw new Error(`요청이 ${Math.round(timeoutMs / 1000)}초 안에 끝나지 않았습니다.`); + } + throw error; } finally { window.clearTimeout(timeoutId); } @@ -186,6 +224,14 @@ export async function fetchSurfacePointCloud( ); } +/** 확정 지표면 구성 + 지형 가장자리만 조회한다(수 KB). + * 포인트클라우드 전체(수십 MB)를 받지 않고도 3D 좌표 환산에 필요한 값을 얻는다. */ +export async function fetchConfirmedSurface(projectId: string): Promise { + return requestJson(`/projects/${projectId}/surface/confirmed`, { + method: "GET", + }); +} + export async function fetchSurfaceGroundStats( projectId: string, ): Promise { @@ -230,3 +276,115 @@ export async function fetchGisGeoJson(projectId: string, layer: string): Promise method: "GET", }); } + +/* ── 배수유역 분석 (B04_wf1_Surface_Router_Watershed.py) ──────────────────── + * 관리자 확인용. 계획 노선(B03 CSV) + 도엽 등고선·세류선으로 유역을 끝까지 분석하고 + * 결과를 영구저장소에 남긴다. 30초 안팎이 걸리므로 여기서 한 번만 돌린다. + * ------------------------------------------------------------------------ */ + +/** 관 매설 지점 1개. reason: stream=세류 교차, spacing=간격 보충, confirmed=사용자 확정. */ +export interface WatershedPipe { + chainage_m: number; + x: number; + y: number; + lon: number; + lat: number; + reason: string; + stream_name: string | null; +} + +/** 1차 배수유역 근거(단계 검증용). TIN·흐름 계산 없이 세류 상·하류 판정과 격자 범위만 준다. */ +export interface WatershedAnalysis { + status: string; + project_id: string; + /** 분석에 쓴 계획 노선 파일명(B03 업로드). */ + route_source: string; + radius_m: number; + /** 도로와 만난 세류선의 상류측 = 1차 영역의 기준선. */ + upstream_lines: Array>; + /** 교차했으나 하류로 판정해 제외한 조각. 판정이 맞는지 눈으로 대조하는 용도. */ + downstream_lines: Array>; + /** 상·하류 어느 망에도 이어지지 않아 제외한 세류 조각 수. */ + no_contact_count: number; + /** 노선이 1차 영역 밖으로 나간 길이(m). 크면 반경을 올려야 한다는 신호. */ + road_outside_m: number; + /** 1차 영역(상류 세류망 버퍼 합집합)의 외곽 링 목록. */ + region_rings: Array>; + grid: { + cell_m: number; + rows: number; + cols: number; + /** bbox 전체 셀 수(참고값). */ + bbox_cells: number; + /** 1차 영역에 걸쳐 실제로 생성된 셀 수. */ + cells: number; + width_m: number; + height_m: number; + /** 격자 bbox 링. 화면은 이 사각형을 rows×cols로 나눠 셀 좌표를 얻는다. */ + bbox_lonlat: Array<[number, number]>; + /** 실제 생성된 셀 구간 [행, 시작열, 끝열(포함)]. 낱개 셀 대신 구간으로 온다. */ + row_spans: Array<[number, number, number]>; + }; + /** 최외곽 적색 셀 주변 확장 결과. */ + expansion: { + rounds: number; + /** 새로 추가한 셀에 적색이 없어 스스로 멈췄는가. */ + closed: boolean; + added_cells: number; + /** 확장 전(1차 영역) 셀 수. */ + initial_cells: number; + }; + /** 셀별 흐름 방향과 도로 도달 여부. 등고선이 없어 판정을 못하면 null. */ + flow: { + encoding: "base64-uint8"; + /** 방위 분해능(32). 코드 0 = 화면 오른쪽, 시계방향 증가. */ + azimuth_steps: number; + /** 제자리(더 낮은 이웃 없음)를 뜻하는 코드. */ + sink_code: number; + /** 표고가 없어 판정 못한 셀 코드. */ + invalid_code: number; + cells: number; + reaches_road: number; + no_road: number; + /** 격자에는 있으나 등고선 TIN 밖이라 표고가 없어 판정 못한 셀. */ + unanalyzed: number; + /** 확정된 상류 세류망을 따라 흐름을 강제로 새긴 셀 수. */ + burned: number; + outer_seeds: number; + interior_seeds: number; + /** 셀당 1바이트. 하위 6비트=32방위 코드(32=제자리, 33=무효), 0x80=도로 도달. + * 순서는 grid.row_spans를 행 → 구간 → 열 오름차순으로 훑은 순서와 같다. */ + data: string; + } | null; + /** 2차 전체 배수유역 외곽선(= 분수령). 적색 셀 전체의 외곽. */ + basin_polygon_lonlat: Array<[number, number]>; + basin_area_m2: number; + /** 도로 위 흐름 강도 — [누가거리 m, 그 구간으로 모이는 상류 면적 ㎡]. */ + strength_profile: Array<[number, number]>; + /** 기본 관 매설 위치 — 도로 × 세류선 교차점. */ + pipes: WatershedPipe[]; + /** B05용 평균 흐름 화살표 — [lon, lat, 방위(도), 도로도달, 셀 수]. + * 세류·도로 셀을 뺀 10m 블록 평균이라 사면 경향만 남는다. */ + flow_arrows: Array<[number, number, number, boolean, number]>; + /** 화살표 사이 실제 간격(m). 화면이 화살표를 이보다 짧게 그려 서로 닿지 않게 한다. */ + arrow_spacing_m: number; + /** 계산하지 않고 저장분을 그대로 돌려준 응답인지. */ + from_cache: boolean; + /** 영구저장소에 남긴 검증용 GeoJSON 경로. */ + saved_to: string | null; +} + +/** 배수유역 분석 결과를 받는다. + * + * `refresh`를 주지 않으면 영구저장소에 남은 결과를 그대로 받아 즉시 끝난다. + * `refresh=true`면 처음부터 다시 계산하므로 수십 초가 걸린다. */ +export async function fetchWatershedAnalysis( + projectId: string, + refresh = false, +): Promise { + return requestJson( + `/projects/${projectId}/drainage/primary-region?refresh=${refresh}`, + { method: "GET" }, + refresh ? API_ANALYSIS_TIMEOUT_MS : API_TIMEOUT_MS, + ); +} diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine.py b/B04_wf1_Surface/B04_wf1_Surface_Engine.py index 097ee188..83bcd7b1 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine.py @@ -232,14 +232,30 @@ def run_surface_analysis( ) prj_path = prj_candidates[0] if prj_candidates else project_root / "result.prj" - bounds_dict_for_download = { + from B04_wf1_Surface.B04_wf1_Surface_Engine_Extent import ( + download_extent, + map_meta_covers, + satellite_extent, + ) + from B04_wf1_Surface.B04_wf1_Surface_Engine_GisVector import download_all_gis_vectors + from B04_wf1_Surface.B04_wf1_Surface_Engine_VWorld import ( + download_vworld_satellite_map, + get_epsg_from_prj, + ) + + las_bounds_dict = { "x": [float(bounds[0, 0]), float(bounds[0, 1])], "y": [float(bounds[1, 0]), float(bounds[1, 1])], "z": [float(bounds[2, 0]), float(bounds[2, 1])], } - - from B04_wf1_Surface.B04_wf1_Surface_Engine_GisVector import download_all_gis_vectors - from B04_wf1_Surface.B04_wf1_Surface_Engine_VWorld import download_vworld_satellite_map + project_epsg = "EPSG:5186" + if prj_path.exists(): + project_epsg = get_epsg_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore")) + # 국가 GIS 벡터는 라이다∪계획노선 범위로 받는다. + bounds_dict_for_download = download_extent(project_root, las_bounds_dict, project_epsg) + # 배경 지도(위성·하이브리드·백지도)는 계획노선이 걸치는 기준 도엽의 도곽 범위로 받는다 + # — 수치지형도 도엽과 같은 눈금. 주변 도엽은 받지 않는다(2026-08-01 사용자 지시). + map_bounds_for_download = satellite_extent(project_root, las_bounds_dict, project_epsg) # VWorld 지도 및 GIS 데이터 저장 위치는 B04_wf1_Surface/processed에 보관. layers = [ @@ -249,13 +265,15 @@ def run_surface_analysis( ] for item in layers: meta_path = processed_dir / f"vworld_{item['layer'].lower()}_meta.json" - if not rebuild and meta_path.is_file(): + # 파일이 있어도 계획노선·여유 셀이 바뀌어 범위를 못 덮으면 다시 받는다. + # (B03 업로드가 부르는 경로는 rebuild=False라, 존재 여부만 보면 영영 갱신되지 않는다.) + if not rebuild and map_meta_covers(meta_path, map_bounds_for_download): continue try: step_started = time.monotonic() download_vworld_satellite_map( prj_path, - bounds_dict_for_download, + map_bounds_for_download, processed_dir, layer_name=item["layer"], ext=item["ext"], @@ -284,32 +302,26 @@ def run_surface_analysis( except Exception as exc: logger.warning("B04 국가 GIS 벡터 다운로드 실패: %s", exc) - # 3-3. 1:5,000 수치지형도 도엽 3x3(9매) 확보 → 프로젝트 영구저장소 + # 3-3. 1:5,000 수치지형도 도엽 확보 → 프로젝트 영구저장소 + # 기준은 계획노선 시점·종점 (같은 도엽이면 9매, 이웃 도엽에 걸치면 12매). # (실패해도 분석은 계속 — 폴백은 수동 다운로드 + 인제스트) _report(92, "download_maps", "수치지형도 도엽 확보 중") try: - from pyproj import Transformer - - from B04_wf1_Surface.B04_wf1_Surface_Engine_MapSheet import ( - latlon_to_sheet5k, - neighbors_3x3, - ) + from B04_wf1_Surface.B04_wf1_Surface_Engine_Extent import sheet_reference_points_wgs84 + from B04_wf1_Surface.B04_wf1_Surface_Engine_MapSheet import neighbors_for_points from B04_wf1_Surface.B04_wf1_Surface_Engine_SheetStore import ( ensure_sheets, get_project_map_sheets_dir, + prune_sheets, ) - from B04_wf1_Surface.B04_wf1_Surface_Engine_VWorld import get_epsg_from_prj - src_epsg = "EPSG:5186" - if prj_path.exists(): - src_epsg = get_epsg_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore")) - transformer = Transformer.from_crs(src_epsg, "EPSG:4326", always_xy=True) - center_lon, center_lat = transformer.transform( - (bounds_dict_for_download["x"][0] + bounds_dict_for_download["x"][1]) / 2.0, - (bounds_dict_for_download["y"][0] + bounds_dict_for_download["y"][1]) / 2.0, - ) step_started = time.monotonic() - sheet_grid = neighbors_3x3(latlon_to_sheet5k(center_lat, center_lon)) + # 도엽 기준은 계획노선 시점·종점 — 노선이 두 도엽에 걸치면 양쪽 주변까지 확보한다. + # 계획노선이 없으면 라이다 범위 중심으로 되돌아간다(2026-08-01 사용자 지시). + reference_points = sheet_reference_points_wgs84( + project_root, las_bounds_dict, project_epsg + ) + sheet_grid = neighbors_for_points(reference_points) sheet_store = get_project_map_sheets_dir(project_root) sheet_result = ensure_sheets(sheet_store, sheet_grid) if sheet_result["failed"]: @@ -319,6 +331,8 @@ def run_surface_analysis( len(sheet_result["available"]), time.monotonic() - step_started, ) + # 선정에서 빠진 zip은 쓰이지 않으므로 정리한다(다른 지역 잔재 포함). + prune_sheets(sheet_store, sheet_grid) # 확보된 도엽을 레이어별 병합 GeoJSON으로 산출 (기존 산출물 있으면 스킵) if sheet_result["available"] and ( diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py new file mode 100644 index 00000000..e84fc474 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py @@ -0,0 +1,204 @@ +# B04_wf1_Surface_Engine_Extent.py +# 전처리에서 내려받을 범위와 기준 좌표를 정한다. +# +# 배경(2026-08-01 사용자 지시): 배경 지도·수치지형도 도엽의 기준을 라이다 범위 한가운데로 +# 잡으면, 계획노선이 라이다 범위를 벗어날 때 배경과 도엽이 노선을 덮지 못한다. +# 계획노선(B03 CSV)의 시점·종점을 기준으로 삼고, 없을 때만 라이다 범위로 되돌린다. + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +_ROUTE_CSV_GLOB = "B03_FileInput/input/csv/*.csv" + + +def read_planned_route(project_root: Path) -> dict[str, Any] | None: + """B03 계획노선 CSV를 읽어 좌표계·범위·시점·종점을 돌려준다. 없거나 형식이 어긋나면 None.""" + from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_planned_route_csv + + for csv_path in sorted(project_root.glob(_ROUTE_CSV_GLOB)): + try: + return analyze_planned_route_csv(csv_path) + except (OSError, ValueError) as exc: + logger.warning("B04 계획노선 CSV 해석 실패: %s (%s)", csv_path.name, exc) + return None + + +def _to_target_crs( + points: list[tuple[float, float]], source_epsg: int | None, target_epsg: str +) -> list[tuple[float, float]]: + """계획노선 좌표를 라이다 좌표계로 옮긴다. 좌표계가 같거나 알 수 없으면 그대로 쓴다.""" + if source_epsg is None: + return points + source = f"EPSG:{source_epsg}" + if source.upper() == target_epsg.upper(): + return points + from pyproj import Transformer + + transformer = Transformer.from_crs(source, target_epsg, always_xy=True) + return [transformer.transform(x, y) for x, y in points] + + +def download_extent( + project_root: Path, + las_bounds: dict[str, list[float]], + target_epsg: str, +) -> dict[str, list[float]]: + """배경 지도가 반드시 덮어야 할 범위 = 라이다 범위 ∪ 계획노선 범위. + + 여유폭은 여기서 미터로 더하지 않는다. 내려받기 쪽에서 이 범위를 덮는 타일을 정한 뒤 + 바깥으로 `SURFACE_MAP_MARGIN_TILES` 겹만큼 주변 셀을 더 받는다(2026-08-01 사용자 지시). + + las_bounds/반환값 모두 {"x": [최소, 최대], "y": [...], "z": [...]} 꼴(라이다 좌표계). + """ + x_min, x_max = float(las_bounds["x"][0]), float(las_bounds["x"][1]) + y_min, y_max = float(las_bounds["y"][0]), float(las_bounds["y"][1]) + + route = read_planned_route(project_root) + if route: + bounds = route["bounds"] + corners = [ + (float(bounds["x_min"]), float(bounds["y_min"])), + (float(bounds["x_max"]), float(bounds["y_max"])), + ] + try: + moved = _to_target_crs(corners, route.get("epsg"), target_epsg) + except Exception as exc: + logger.warning("B04 계획노선 좌표 변환 실패 — 라이다 범위만 사용 (%s)", exc) + moved = [] + for x, y in moved: + x_min, x_max = min(x_min, x), max(x_max, x) + y_min, y_max = min(y_min, y), max(y_max, y) + + return { + "x": [x_min, x_max], + "y": [y_min, y_max], + "z": list(las_bounds.get("z", [0.0, 0.0])), + } + + +def satellite_extent( + project_root: Path, + las_bounds: dict[str, list[float]], + target_epsg: str, +) -> dict[str, list[float]]: + """배경 지도를 확보할 범위 = 계획노선이 걸치는 **기준 도엽**의 도곽 범위. + + 수치지형도 도엽과 같은 눈금으로 맞춘다 — 도엽 1매면 1매 크기, 2~3매에 걸치면 그만큼. + 주변 도엽은 받지 않는다(2026-08-01 사용자 지시). + 도엽 번호를 얻지 못하면 라이다∪계획노선 범위로 되돌아간다. + """ + from pyproj import Transformer + + from .B04_wf1_Surface_Engine_MapSheet import sheet5k_to_bounds, sheets_for_points + + points = sheet_reference_points_wgs84(project_root, las_bounds, target_epsg) + sheets = sheets_for_points(points) + if not sheets: + return download_extent(project_root, las_bounds, target_epsg) + + lon_min = lat_min = float("inf") + lon_max = lat_max = float("-inf") + for sheet_no in sheets: + s_lon_min, s_lat_min, s_lon_max, s_lat_max = sheet5k_to_bounds(sheet_no) + lon_min, lon_max = min(lon_min, s_lon_min), max(lon_max, s_lon_max) + lat_min, lat_max = min(lat_min, s_lat_min), max(lat_max, s_lat_max) + + to_target = Transformer.from_crs("EPSG:4326", target_epsg, always_xy=True) + x0, y0 = to_target.transform(lon_min, lat_min) + x1, y1 = to_target.transform(lon_max, lat_max) + logger.info("B04 배경 지도 기준 도엽 %d매: %s", len(sheets), ", ".join(sheets)) + return { + "x": [min(x0, x1), max(x0, x1)], + "y": [min(y0, y1), max(y0, y1)], + "z": list(las_bounds.get("z", [0.0, 0.0])), + } + + +def planned_route_bounds(project_root: Path, target_epsg: str) -> dict[str, float] | None: + """계획노선(B03 CSV)의 평면 범위를 프로젝트 좌표계로 돌려준다. 없으면 None. + + 지도(2D) 초기 화면을 도로 기준으로 맞출 때 쓴다 — 화면 쪽은 도로 범위만 알면 된다. + """ + route = read_planned_route(project_root) + if not route: + return None + bounds = route["bounds"] + corners = [ + (float(bounds["x_min"]), float(bounds["y_min"])), + (float(bounds["x_max"]), float(bounds["y_max"])), + ] + try: + moved = _to_target_crs(corners, route.get("epsg"), target_epsg) + except Exception as exc: + logger.warning("B04 계획노선 범위 변환 실패 (%s)", exc) + return None + xs = [point[0] for point in moved] + ys = [point[1] for point in moved] + return {"x_min": min(xs), "x_max": max(xs), "y_min": min(ys), "y_max": max(ys)} + + +def project_epsg_from_prj(project_root: Path) -> str: + """프로젝트 PRJ에서 좌표계를 읽는다. 없으면 중부원점(EPSG:5186).""" + from .B04_wf1_Surface_Engine_VWorld import get_epsg_from_prj + + for prj_path in sorted(project_root.glob("B03_FileInput/**/*.prj")): + return get_epsg_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore")) + return "EPSG:5186" + + +def map_meta_covers(meta_path: Path, extent: dict[str, list[float]]) -> bool: + """저장된 배경 지도가 필요한 범위를 이미 덮고 있는가. + + 파일 존재 여부만 보면 계획노선이 바뀌거나 여유 셀 설정을 바꿔도 옛 사진을 계속 쓴다 + (B03 업로드 → 전처리 경로는 `rebuild=False`라 더더욱 다시 받지 않는다, 2026-08-01). + """ + if not meta_path.is_file(): + return False + try: + meta = json.loads(meta_path.read_text(encoding="utf-8")) + return ( + float(meta["x_min"]) <= extent["x"][0] + and float(meta["x_max"]) >= extent["x"][1] + and float(meta["y_min"]) <= extent["y"][0] + and float(meta["y_max"]) >= extent["y"][1] + ) + except (OSError, ValueError, KeyError, TypeError): + return False + + +def sheet_reference_points_wgs84( + project_root: Path, + las_bounds: dict[str, list[float]], + target_epsg: str, +) -> list[tuple[float, float]]: + """도엽 선정 기준 좌표(위도, 경도) 목록. + + ① 계획노선 시점·종점 → ② 라이다 범위 중심(폴백). + 시점과 종점이 서로 다른 도엽에 걸치면 호출측이 두 도엽의 주변을 모두 확보한다. + """ + from pyproj import Transformer + + to_wgs84 = Transformer.from_crs(target_epsg, "EPSG:4326", always_xy=True) + + route = read_planned_route(project_root) + if route: + ends = [ + (float(route["start_point"][0]), float(route["start_point"][1])), + (float(route["end_point"][0]), float(route["end_point"][1])), + ] + try: + moved = _to_target_crs(ends, route.get("epsg"), target_epsg) + return [(lat, lon) for lon, lat in (to_wgs84.transform(x, y) for x, y in moved)] + except Exception as exc: + logger.warning("B04 계획노선 기준 좌표 산출 실패 — 라이다 중심 사용 (%s)", exc) + + center_x = (float(las_bounds["x"][0]) + float(las_bounds["x"][1])) / 2.0 + center_y = (float(las_bounds["y"][0]) + float(las_bounds["y"][1])) / 2.0 + lon, lat = to_wgs84.transform(center_x, center_y) + return [(lat, lon)] diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_MapSheet.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_MapSheet.py index e1b0c142..aae948ad 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_MapSheet.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_MapSheet.py @@ -88,3 +88,36 @@ def neighbors_3x3(sheet_no: str) -> list[str]: latlon_to_sheet5k(lat_c + dr * SHEET5K_SIZE_DEG, lon_c + dc * SHEET5K_SIZE_DEG) ) return result + + +def sheets_for_points(points: list[tuple[float, float]]) -> list[str]: + """기준 좌표들이 속한 도엽번호만(주변 도엽 없음, 중복 제거). + + 계획노선 시점·종점이 같은 도엽이면 1매, 걸치면 2~3매가 된다. + 배경 지도(위성사진)는 이 도엽 범위만 확보한다(2026-08-01 사용자 지시). + """ + ordered: list[str] = [] + seen: set[str] = set() + for lat, lon in points: + sheet_no = latlon_to_sheet5k(lat, lon) + if sheet_no not in seen: + seen.add(sheet_no) + ordered.append(sheet_no) + return ordered + + +def neighbors_for_points(points: list[tuple[float, float]]) -> list[str]: + """기준 좌표들이 속한 도엽 + 각각의 주변 8매를 합친 목록(중복 제거, 순서 유지). + + 계획노선 시점·종점이 같은 도엽이면 9매, 이웃한 두 도엽에 걸치면 12매가 된다 + (3×3 두 벌이 한 줄을 공유하므로 3×4). 도엽 하나가 늘 때마다 병합 산출물도 늘어나므로 + 기준 좌표는 노선의 양 끝만 쓴다(2026-08-01 사용자 지시). + """ + ordered: list[str] = [] + seen: set[str] = set() + for lat, lon in points: + for sheet_no in neighbors_3x3(latlon_to_sheet5k(lat, lon)): + if sheet_no not in seen: + seen.add(sheet_no) + ordered.append(sheet_no) + return ordered diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_SheetStore.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_SheetStore.py index 8766e65d..f513143e 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_SheetStore.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_SheetStore.py @@ -18,6 +18,7 @@ import base64 import datetime import http.cookiejar import json +import logging import re import shutil import tempfile @@ -36,6 +37,8 @@ from config.config_system import ( from .B04_wf1_Surface_Engine_MapSheet import latlon_to_sheet5k, sheet5k_to_bounds +logger = logging.getLogger(__name__) + # 도곽선 레이어 코드 (수치지형도 v2.0 도엽본) _SHEET_FRAME_CODE = "A0010000" _SHEET_NO_RE = re.compile(r"(\d{8})") @@ -227,6 +230,39 @@ def get_sheet_path(store_dir: str | Path, sheet_no: str) -> Path | None: return path if path.exists() else None +def prune_sheets(store_dir: str | Path, keep_sheet_nos: list[str]) -> list[str]: + """선정 도엽에 없는 zip을 지우고 지운 도엽번호를 돌려준다. + + 도엽 기준이 바뀌거나 다른 지역 파일이 섞여 들어오면 쓰지 않는 zip이 계속 쌓인다 + (표본 프로젝트에서 30매 중 21매가 다른 지역 잔재였다, 2026-08-01). + 병합에는 선정 도엽만 쓰이므로 산출물은 그대로다. + """ + store = Path(store_dir) + if not store.is_dir(): + return [] + keep = {str(sheet_no) for sheet_no in keep_sheet_nos} + index = _load_index(store) + removed: list[str] = [] + + for zip_path in sorted(store.glob("*.zip")): + match = _SHEET_NO_RE.fullmatch(zip_path.stem) + if not match or match.group(1) in keep: + continue + sheet_no = match.group(1) + try: + zip_path.unlink() + except OSError as exc: + logger.warning("도엽 정리: %s 삭제 실패 (%s)", zip_path.name, exc) + continue + index["sheets"].pop(sheet_no, None) + removed.append(sheet_no) + + if removed: + _save_index(store, index) + logger.info("도엽 정리: 미사용 %d매 삭제 (%s)", len(removed), ", ".join(removed)) + return removed + + def missing_sheets(store_dir: str | Path, sheet_nos: list[str]) -> list[str]: """요청 도엽 중 영구저장소에 없는 번호 목록.""" store = Path(store_dir) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_VWorld.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_VWorld.py index bfba30f6..2746cb87 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_VWorld.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_VWorld.py @@ -19,8 +19,14 @@ try: VWORLD_API_KEY = getattr( config_system, "VWORLD_API_KEY", "3DBD7306-7DBD-38BB-B292-267C5ED7AC6B" ) + SURFACE_MAP_MAX_TILES_PER_SIDE = getattr(config_system, "SURFACE_MAP_MAX_TILES_PER_SIDE", 12) + SURFACE_MAP_MAX_ZOOM = getattr(config_system, "SURFACE_MAP_MAX_ZOOM", 18) + SURFACE_MAP_MIN_ZOOM = getattr(config_system, "SURFACE_MAP_MIN_ZOOM", 14) except ImportError: VWORLD_API_KEY = "3DBD7306-7DBD-38BB-B292-267C5ED7AC6B" + SURFACE_MAP_MAX_TILES_PER_SIDE = 12 + SURFACE_MAP_MAX_ZOOM = 18 + SURFACE_MAP_MIN_ZOOM = 14 def get_epsg_from_prj(prj_content: str) -> str: @@ -96,29 +102,22 @@ def download_vworld_satellite_map( lon_min, lat_min = transformer.transform(x_min, y_min) lon_max, lat_max = transformer.transform(x_max, y_max) - # 3. 지도 타일 크기 결정 (ZOOM 18 초고해상도 적용) - zoom = 18 - - # 영역을 포괄하는 좌상단 타일, 우하단 타일 인덱스 산출 - x1, y1 = latlon_to_tile(lat_max, lon_min, zoom) - x2, y2 = latlon_to_tile(lat_min, lon_max, zoom) - - # 타일 경계 마진 패딩 - x_start = min(x1, x2) - 1 - x_end = max(x1, x2) + 1 - y_start = min(y1, y2) - 1 - y_end = max(y1, y2) + 1 - - tile_w = x_end - x_start + 1 - tile_h = y_end - y_start + 1 - - # 너무 많은 타일을 다운로드하여 IP 차단되는 것을 방지 - if tile_w > 15: - tile_w = 15 - x_end = x_start + 14 - if tile_h > 15: - tile_h = 15 - y_end = y_start + 14 + # 3. 요청 범위를 그대로 덮는 타일 범위를 잡는다(주변으로 넓히지 않는다). + # 한 변 타일 수가 한도를 넘으면 zoom을 한 단계씩 낮춘다 — 도엽 1매를 zoom 18로 받으면 + # 한 변이 19타일(4,864px)이라 파일이 지나치게 커진다(2026-08-01 사용자 지시). + zoom = SURFACE_MAP_MAX_ZOOM + while True: + x1, y1 = latlon_to_tile(lat_max, lon_min, zoom) + x2, y2 = latlon_to_tile(lat_min, lon_max, zoom) + x_start, x_end = min(x1, x2), max(x1, x2) + y_start, y_end = min(y1, y2), max(y1, y2) + tile_w = x_end - x_start + 1 + tile_h = y_end - y_start + 1 + if zoom <= SURFACE_MAP_MIN_ZOOM: + break + if max(tile_w, tile_h) <= SURFACE_MAP_MAX_TILES_PER_SIDE: + break + zoom -= 1 # 4. 개별 타일 다운로드 및 이미지 병합 map_img = Image.new("RGBA", (tile_w * 256, tile_h * 256)) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Analyze.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Analyze.py new file mode 100644 index 00000000..8955406c --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Analyze.py @@ -0,0 +1,346 @@ +"""배수유역 분석 오케스트레이터 (B04 — 관리자 확인용 전처리). + +계획 노선(B03 업로드 파일)과 도엽 등고선·세류선만으로 배수유역을 끝까지 분석해 +영구저장소에 남긴다. 30초 안팎이 걸리는 무거운 작업이라 여기서 한 번만 돌리고, +일반 사용자가 쓰는 B05는 그 결과를 읽어 쓰기만 한다(2026-07-31 사용자 지시). + + ① 도로 교차 세류망 상류측 추출 → 반경 버퍼 = 1차 배수유역 + ② 도로 시작점 기준 격자 생성 (1차 영역에 걸치는 셀만) + ③ 등고선 하강 방향 — 높은 등고 라인에서 낮은 등고 라인으로. 보간면을 쓰지 않으므로 + 가짜 웅덩이·평탄면이 원리적으로 생기지 않는다 + ④ 세류망 흐름 새김 → 최외곽부터 사슬 추적 → 도로 도달 여부(적/청) 판정 + ⑤ 최외곽 적색 셀 주변 확장 — 새로 추가한 셀에 적색이 없을 때까지 + ⑥ 도로 셀별 흐름 강도 + ⑦ 2차 전체 배수유역 외곽선 + ⑧ 기본 관 매설 위치 (도로 × 세류선 교차점) + +⑨ 이후(관 최소 개수 보충, 세부유역 분할)는 사용자가 관을 옮길 수 있어야 하므로 +B05에 남긴다. +""" + +from __future__ import annotations + +import logging +import math +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 ( + AZIMUTH_STEPS, + 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_ARROW_BLOCK_M, + DRAINAGE_ARROW_MIN_AGREEMENT, + DRAINAGE_ARROW_MIN_COVERAGE, + DRAINAGE_ARROW_SPACING_M, + 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 + # B05용 평균 흐름 화살표 — (x, y, 방위 라디안, 도로 도달, 셀 수). + flow_arrows: list[tuple[float, float, float, bool, int]] = field(default_factory=list) + # ⑧ 기본 관 매설 위치 — 도로 × 세류선 교차점. + 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) + + # B05에 얹을 평균 흐름 화살표 — 셀 화살표는 도면 배율에서 안 보인다. + flow_arrows = build_flow_arrows(analysis, analysis.flow) + + 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, + flow_arrows=flow_arrows, + ) + + +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 build_flow_arrows(analysis: Any, flow: Any) -> list[tuple[float, float, float, bool, int]]: + """셀 흐름을 블록 단위로 평균해 B05에 얹을 화살표를 뽑는다. + + 셀 화살표는 1m라 도면 배율에서 경향이 안 보인다. 겹치지 않는 블록으로 나눠 방향을 + 평균하고, 화살표끼리 최소 간격을 두어 솎아낸다(2026-07-31 사용자 지시). + + **세류선 셀과 도로 셀은 뺀다.** 그 자리 흐름은 지형 경사가 아니라 확정된 물길·노면을 + 따르는 값이라 사면 경향을 왜곡한다. + + 방향 평균은 산술평균이 아니라 **원형 평균**으로 낸다(0°와 359°의 평균은 180°가 아니라 + 0°다). 평균 벡터 길이가 일치도이므로, 블록 안 방향이 제각각이면 그 블록은 버린다. + """ + spec = analysis.spec + rows, cols = spec.n_rows, spec.n_cols + direction = flow.direction.reshape(rows, cols) + usable = ( + analysis.domain + & flow.analyzed.reshape(rows, cols) + & (direction < AZIMUTH_STEPS) # 싱크·무효 제외 + & ~analysis.road.mask + ) + if flow.burned is not None: + usable &= ~flow.burned.reshape(rows, cols) + if not usable.any(): + return [] + + block = max(1, int(round(DRAINAGE_ARROW_BLOCK_M / spec.cell_m))) + stride = max(1, int(round(DRAINAGE_ARROW_SPACING_M / (block * spec.cell_m)))) + angle = direction.astype(np.float64) * (2.0 * math.pi / AZIMUTH_STEPS) + reaches = flow.reaches_road.reshape(rows, cols) + + arrows: list[tuple[float, float, float, bool, int]] = [] + for row0 in range(0, rows - block + 1, block * stride): + for col0 in range(0, cols - block + 1, block * stride): + window = usable[row0 : row0 + block, col0 : col0 + block] + count = int(window.sum()) + if count < DRAINAGE_ARROW_MIN_COVERAGE * block * block: + continue + local = angle[row0 : row0 + block, col0 : col0 + block][window] + mean_x = float(np.cos(local).mean()) + mean_y = float(np.sin(local).mean()) + agreement = math.hypot(mean_x, mean_y) + if agreement < DRAINAGE_ARROW_MIN_AGREEMENT: + continue # 방향이 제각각인 블록 — 평균이 경향을 대표하지 못한다 + centre_row = row0 + block / 2.0 + centre_col = col0 + block / 2.0 + arrows.append( + ( + spec.x_min + centre_col * spec.cell_m, + spec.y_max - centre_row * spec.cell_m, + math.atan2(mean_y, mean_x), + bool(reaches[row0 : row0 + block, col0 : col0 + block][window].mean() >= 0.5), + count, + ) + ) + logger.info( + "배수유역: 평균 흐름 화살표 %d개 (블록 %.0fm, 간격 %.0fm, 세류·도로 셀 제외)", + len(arrows), + block * spec.cell_m, + block * stride * spec.cell_m, + ) + return arrows + + +def _base_pipes( + vertices: list[RouteVertex], stream_features: list[dict[str, Any]] +) -> list[StructureCandidate]: + """도로 × 세류선 교차점을 기본 관 위치로 삼는다. 300m 보충 배치는 다음 단계다.""" + pipes: list[StructureCandidate] = [] + for candidate in find_stream_crossings(vertices, stream_features): + if pipes and candidate.chainage_m - pipes[-1].chainage_m < DRAINAGE_PIPE_MIN_SPACING_M: + continue + pipes.append(candidate) + return pipes + + +# ── 흐름 강도 곡선 ────────────────────────────────────────────────────────── + + +def _strength_by_chainage( + road_chainage: np.ndarray, strength_area: np.ndarray, total_length: float +) -> np.ndarray: + """도로 셀 강도를 1m 누가거리 구간으로 합산한 곡선(㎡/m 구간 합).""" + bins = max(1, int(np.ceil(total_length)) + 1) + if road_chainage.size == 0: + return np.zeros(bins) + index = np.clip(np.round(road_chainage).astype(np.int64), 0, bins - 1) + return np.bincount(index, weights=strength_area, minlength=bins) + + +def _downsample_strength(curve: np.ndarray) -> list[tuple[float, float]]: + """응답용으로 강도 곡선을 일정 간격으로 줄인다(구간 합 유지). + + 끝자락을 잘라내면 종점 부근 유입 면적이 통째로 사라지므로 0으로 채워 맞춘다. + """ + step = max(1, int(_STRENGTH_OUTPUT_STEP_M)) + if curve.size == 0: + return [] + padding = (-curve.size) % step + padded = np.append(curve, np.zeros(padding)) if padding else curve + summed = padded.reshape(-1, step).sum(axis=1) + return [ + (float(position * step), float(value)) for position, value in enumerate(summed) if value > 0 + ] diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Descent.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Descent.py new file mode 100644 index 00000000..98e87368 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Descent.py @@ -0,0 +1,265 @@ +"""등고선 기반 흐름 방향 — 높은 등고 라인에서 낮은 등고 라인으로 내려가며 방향을 세운다. + +기존 방식(등고선 → TIN 보간 → 지표면 기울기 → D8)은 보간면이 만든 가짜 웅덩이와 평탄 +삼각형 때문에 흐름이 중간에서 끊겼다. 실데이터에서 채움·평탄해소를 거치고도 싱크가 수천 개 +남았고, 그 싱크 하나가 상류 유역 전체를 통째로 삼켰다. + +여기서는 **보간면을 거치지 않는다.** 등고선을 격자에 직접 굽고, 셀마다 "내가 속한 등고 +라인보다 한 단 낮은 등고 라인"이 어디인지를 찾아 그쪽으로 방향을 준다(2026-07-31 사용자 지시). + + ① 등고선을 격자에 굽는다 — 셀이 어느 표고의 라인 위인지 기록 + ② 셀마다 가장 가까운 등고 라인을 찾아 그 표고를 '밴드'로 삼는다 + ③ 표고가 높은 밴드부터 내려오며, 각 밴드에서 **한 단 낮은 등고 라인까지의 거리**를 잰다 + ④ 위치에너지 = 밴드 순위 × 큰 수 + 그 거리 + ⑤ 수신 셀 = 위치에너지가 더 낮은 8이웃 중 화살표 방향에 가장 가까운 셀 + +④의 위치에너지는 흐름을 따라 반드시 감소한다. 그래서 **순환도 웅덩이도 원리적으로 생기지 +않는다** — 채움이나 평탄면 해소가 아예 필요 없다. + +⑤ 덕분에 화면 화살표(32방위)와 실제 추적 경로가 항상 같은 방향을 가리킨다. 예전에는 +화살표는 기울기, 추적은 D8이라 서로 어긋나 눈으로 검증할 수가 없었다. +""" + +from __future__ import annotations + +import logging +import math +from dataclasses import dataclass +from typing import Any + +import numpy as np +from rasterio.features import rasterize +from scipy.ndimage import distance_transform_edt +from shapely.geometry import shape + +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import ( + AZIMUTH_INVALID, + AZIMUTH_SINK, + AZIMUTH_STEPS, + GridSpec, + _feature_elevation, + grid_transform, + iter_linestrings, +) +from config.config_system import DRAINAGE_CONTOUR_MIN_LENGTH_M + +logger = logging.getLogger(__name__) + +# 8이웃 (행 증분, 열 증분). +_NEIGHBOURS = ( + (-1, -1), + (-1, 0), + (-1, 1), + (0, -1), + (0, 1), + (1, -1), + (1, 0), + (1, 1), +) + + +@dataclass +class ContourDescent: + """등고선에서 직접 세운 흐름 방향 격자.""" + + spec: GridSpec + band_elevation: np.ndarray # (R, C) float32 — 셀이 속한 등고 라인 표고, 무효는 NaN + valid: np.ndarray # (R, C) bool — 방향을 세운 셀 + receiver: np.ndarray # (R*C,) int32 — 다음 셀, 최하단 밴드는 자기 자신 + step_length: np.ndarray # (R*C,) float32 + azimuth: np.ndarray # (R*C,) int16 — 32방위 코드(32=제자리, 33=무효) + levels: list[float] # 사용된 등고 표고(내림차순) + + +def rasterize_contours( + spec: GridSpec, + contour_features: list[dict[str, Any]], + elevation_floor_m: float | None = None, +) -> tuple[np.ndarray, list[float]]: + """등고선을 격자에 굽는다. 셀마다 그 위를 지나는 등고 라인의 표고(없으면 NaN).""" + by_level: dict[float, list[Any]] = {} + for feature in contour_features: + geometry = feature.get("geometry") + if not geometry: + continue + elevation = _feature_elevation(feature.get("properties") or {}) + if elevation is None: + continue + if elevation_floor_m is not None and elevation < elevation_floor_m: + continue + try: + parsed = shape(geometry) + except Exception: # noqa: BLE001 + continue + for line in iter_linestrings(parsed): + if line.length < DRAINAGE_CONTOUR_MIN_LENGTH_M: + continue + by_level.setdefault(float(elevation), []).append(line) + + burned = np.full((spec.n_rows, spec.n_cols), np.nan, dtype=np.float32) + levels = sorted(by_level, reverse=True) + transform = grid_transform(spec) + for elevation in levels: + stamp = rasterize( + [(line, 1) for line in by_level[elevation]], + out_shape=(spec.n_rows, spec.n_cols), + transform=transform, + fill=0, + dtype="uint8", + all_touched=True, + ).astype(bool) + # 낮은 표고부터 덮어써야 겹치는 셀이 낮은 라인으로 남는다 — 물은 낮은 쪽으로 간다. + burned[stamp] = elevation + logger.info( + "배수유역: 등고 라인 %d단(%.0f~%.0fm)을 격자에 굽어 %d셀", + len(levels), + levels[-1] if levels else 0.0, + levels[0] if levels else 0.0, + int(np.isfinite(burned).sum()), + ) + return burned, levels + + +def build_contour_descent( + spec: GridSpec, + contour_features: list[dict[str, Any]], + domain: np.ndarray | None = None, + elevation_floor_m: float | None = None, +) -> ContourDescent: + """등고선만으로 셀별 흐름 방향을 세운다. 보간면을 만들지 않는다.""" + rows, cols = spec.n_rows, spec.n_cols + burned, levels = rasterize_contours(spec, contour_features, elevation_floor_m) + empty = ContourDescent( + spec=spec, + band_elevation=np.full((rows, cols), np.nan, dtype=np.float32), + valid=np.zeros((rows, cols), dtype=bool), + receiver=np.arange(spec.size, dtype=np.int32), + step_length=np.zeros(spec.size, dtype=np.float32), + azimuth=np.full(spec.size, AZIMUTH_INVALID, dtype=np.int16), + levels=levels, + ) + if len(levels) < 2: + logger.warning("배수유역: 등고 라인이 2단 미만이라 방향을 세울 수 없습니다.") + return empty + + on_contour = np.isfinite(burned) + # ② 셀마다 가장 가까운 등고 라인의 표고 = 그 셀의 밴드. + _, (near_row, near_col) = distance_transform_edt(~on_contour, return_indices=True) + band_elevation = burned[near_row, near_col].astype(np.float32) + inside = domain if domain is not None else np.ones((rows, cols), dtype=bool) + band_elevation = np.where(inside, band_elevation, np.nan) + + # ③④ 높은 밴드부터 내려오며 한 단 낮은 등고 라인까지의 거리와 목표 셀을 구한다. + distance = np.full((rows, cols), np.inf, dtype=np.float32) + target_row = np.zeros((rows, cols), dtype=np.int32) + target_col = np.zeros((rows, cols), dtype=np.int32) + band_rank = np.full((rows, cols), -1, dtype=np.int32) + for rank, elevation in enumerate(levels[:-1]): + members = inside & (band_elevation == elevation) + if not members.any(): + continue + lower = on_contour & (burned < elevation) + if not lower.any(): + continue + step_distance, (step_row, step_col) = distance_transform_edt(~lower, return_indices=True) + distance[members] = step_distance[members].astype(np.float32) + target_row[members] = step_row[members] + target_col[members] = step_col[members] + band_rank[members] = len(levels) - 1 - rank # 높을수록 큰 값 + + # 최하단 밴드는 더 내려갈 등고 라인이 없다. 무효로 빼지 않고 **정지 셀**로 남긴다 — + # 그래야 그 자리가 도로·세류선이면 적색으로 잡히고, 아니면 물이 고이는 지점으로 보인다. + lowest = inside & (band_elevation == levels[-1]) & (band_rank < 0) + if lowest.any(): + distance[lowest] = 0.0 + band_rank[lowest] = 0 + + valid = band_rank >= 0 + if not valid.any(): + logger.warning("배수유역: 하강 방향을 세운 셀이 없습니다.") + return empty + + # 밴드가 하나 낮아지면 위치에너지가 반드시 떨어지도록 거리 최대치보다 큰 간격을 준다. + finite = distance[valid & np.isfinite(distance)] + span = (float(finite.max()) if finite.size else 1.0) + 2.0 + distance[valid & ~np.isfinite(distance)] = 0.0 + potential = np.where(valid, band_rank.astype(np.float64) * span + distance, np.inf) + + receiver, step_length, azimuth = _route_by_potential( + spec, potential, valid, target_row, target_col + ) + logger.info( + "배수유역: 등고선 하강 방향 %d셀 (밴드 %d단), 최하단 정지 %d셀", + int(valid.sum()), + int(band_rank[valid].max() - band_rank[valid].min() + 1), + int((azimuth == AZIMUTH_SINK).sum()), + ) + return ContourDescent( + spec=spec, + band_elevation=np.where(valid, band_elevation, np.nan).astype(np.float32), + valid=valid, + receiver=receiver, + step_length=step_length, + azimuth=azimuth, + levels=levels, + ) + + +def _route_by_potential( + spec: GridSpec, + potential: np.ndarray, + valid: np.ndarray, + target_row: np.ndarray, + target_col: np.ndarray, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """위치에너지가 낮은 8이웃 중 **화살표 방향에 가장 가까운** 셀을 수신 셀로 고른다. + + 화살표는 "한 단 낮은 등고 라인 쪽"을 가리키는 연속 방위이고, 수신 셀은 그 방위에 가장 + 가까운 이웃이다. 그래서 화면 화살표와 실제 추적 경로가 어긋나지 않는다. + 위치에너지가 더 낮은 이웃만 후보로 두므로 순환이 생기지 않는다. + """ + rows, cols = spec.n_rows, spec.n_cols + grid_row, grid_col = np.meshgrid(np.arange(rows), np.arange(cols), indexing="ij") + # 목표(한 단 낮은 등고 라인 위의 셀)를 향하는 연속 방위. + aim_row = (target_row - grid_row).astype(np.float64) + aim_col = (target_col - grid_col).astype(np.float64) + aim_norm = np.hypot(aim_row, aim_col) + aim_norm[aim_norm == 0.0] = 1.0 + aim_row /= aim_norm + aim_col /= aim_norm + + padded = np.full((rows + 2, cols + 2), np.inf) + padded[1:-1, 1:-1] = potential + flat_index = np.arange(spec.size, dtype=np.int32).reshape(rows, cols) + padded_index = np.full((rows + 2, cols + 2), -1, dtype=np.int32) + padded_index[1:-1, 1:-1] = flat_index + + best_score = np.full((rows, cols), -np.inf) + receiver = flat_index.copy() + step = np.zeros((rows, cols), dtype=np.float32) + centre = potential + for row_shift, col_shift in _NEIGHBOURS: + neighbour = padded[ + 1 + row_shift : 1 + row_shift + rows, 1 + col_shift : 1 + col_shift + cols + ] + length = math.hypot(row_shift, col_shift) + # 방위 일치도(코사인 유사도)가 클수록 좋은 후보다. + score = (aim_row * row_shift + aim_col * col_shift) / length + better = valid & np.isfinite(neighbour) & (neighbour < centre) & (score > best_score) + if not better.any(): + continue + best_score = np.where(better, score, best_score) + neighbour_index = padded_index[ + 1 + row_shift : 1 + row_shift + rows, 1 + col_shift : 1 + col_shift + cols + ] + receiver = np.where(better, neighbour_index, receiver) + step = np.where(better, np.float32(length * spec.cell_m), step) + + moved = receiver != flat_index + delta_row = (receiver // cols - flat_index // cols).astype(np.float64) + delta_col = (receiver % cols - flat_index % cols).astype(np.float64) + angle = np.arctan2(delta_row, delta_col) + code = np.rint(angle / (2.0 * math.pi / AZIMUTH_STEPS)).astype(np.int16) % AZIMUTH_STEPS + azimuth = np.where(moved, code, AZIMUTH_SINK) + azimuth = np.where(valid, azimuth, AZIMUTH_INVALID) + return receiver.reshape(-1), step.reshape(-1), azimuth.reshape(-1).astype(np.int16) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Expand.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Expand.py new file mode 100644 index 00000000..c24d5982 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Expand.py @@ -0,0 +1,255 @@ +"""해석 영역 확장 — 최외곽의 **적색 셀** 주변으로 넓히며 다시 분석한다. + +적색 셀이 해석 영역 최외곽에 있다는 것은 그 바깥에서 물이 더 흘러 들어온다는 뜻이다. +거기서 멈추면 유역이 잘린다. 반대로 최외곽이 전부 파랑이면 그 바깥 물은 도로로 오지 +않으므로 더 볼 필요가 없다. + + ① 현재 해석 영역의 최외곽 셀 중 **적색**인 것을 찾는다 + ② 그 주변으로 한 겹(설정 폭) 넓힌다 + ③ 넓힌 영역으로 흐름 방향·색을 다시 분석한다 + ④ **새로 추가된 셀에 적색이 하나도 없으면 종료** (2026-07-31 사용자 지시) + +격자 bbox에 닿으면 격자 자체도 셀 정수배로 넓힌다 — 도로 시작점 기준 격자점은 유지된다. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +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, + build_contour_descent, +) +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Flow import ( + FlowClassification, + RoadRaster, + burn_stream_flow, + classify_flow, + outermost_cells, + rasterize_road, +) +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, + DRAINAGE_ROAD_WIDTH_M, +) + +logger = logging.getLogger(__name__) + + +@dataclass +class GridAnalysis: + """한 회차 분석 결과 — 격자·해석 영역·방향·색까지 한 묶음.""" + + spec: GridSpec + domain: np.ndarray # (R, C) bool — 해석 대상 셀 + descent: ContourDescent + terrain: TerrainGrid + road: RoadRaster + flow: FlowClassification + + +@dataclass +class RedExpansion: + """확장 루프 결과.""" + + analysis: GridAnalysis + rounds: int # 실제로 넓힌 횟수 (0 = 처음부터 최외곽에 적색이 없었음) + closed: bool # 새로 추가한 셀에 적색이 없어 스스로 멈췄는가 + added_cells: int # 확장으로 늘어난 셀 수 + + +def analyze_domain( + spec: GridSpec, + domain: np.ndarray, + contour_features: list[dict[str, Any]], + route_line: LineString, + upstream_streams: list[LineString], + elevation_floor_m: float | None = None, + descent: ContourDescent | None = None, +) -> GridAnalysis | None: + """주어진 격자·해석 영역에 대해 등고선 하강 방향과 도로 도달 색을 한 번 계산한다. + + **하강 방향장은 해석 영역과 무관하다** — 등고선 기하만으로 정해진다. 그래서 확장 + 회차마다 다시 계산하지 않고, 격자가 커졌을 때만 새로 만들어 넘겨받는다(`descent`). + 해석 영역은 마지막에 마스크로만 씌운다. + """ + if descent is None or descent.spec != spec: + descent = build_contour_descent(spec, contour_features, None, elevation_floor_m) + valid = descent.valid & domain + if not valid.any(): + return None + terrain = TerrainGrid( + spec=spec, + elevation=np.where(valid, descent.band_elevation, np.nan).astype(np.float32), + valid=valid, + receiver=descent.receiver, + step_length=descent.step_length, + ) + road = rasterize_road(spec, route_line, DRAINAGE_ROAD_WIDTH_M) + terrain, burned = burn_stream_flow(terrain, road, upstream_streams) + flow = classify_flow(terrain, road, burned, azimuth=descent.azimuth) + return GridAnalysis( + spec=spec, domain=domain, descent=descent, terrain=terrain, road=road, flow=flow + ) + + +def expand_by_red_boundary( + spec: GridSpec, + domain: np.ndarray, + contour_features: list[dict[str, Any]], + route_line: LineString, + upstream_streams: list[LineString], + elevation_floor_m: float | None = None, + band_m: float = DRAINAGE_RED_EXPAND_BAND_M, + max_rounds: int = DRAINAGE_RED_EXPAND_MAX_ROUNDS, +) -> RedExpansion | None: + """최외곽 적색 셀 주변으로 넓히며, 새로 추가한 셀에 적색이 없을 때까지 반복한다.""" + band_cells = max(1, int(round(band_m / spec.cell_m))) + # 1차 영역의 bbox는 영역에 딱 붙어 있어 첫 회차부터 격자를 넓혀야 한다. 미리 여유를 + # 두면 방향장을 다시 만들지 않고 해석 영역만 넓히며 몇 회차를 돌 수 있다. + spec, domain = _pad_spec(spec, domain, band_cells * 2) + started_cells = int(domain.sum()) + analysis = analyze_domain( + spec, domain, contour_features, route_line, upstream_streams, elevation_floor_m + ) + if analysis is None: + return None + + rounds = 0 + closed = False + for attempt in range(max_rounds): + current = analysis.spec + reaches = analysis.flow.reaches_road.reshape(current.n_rows, current.n_cols) + rim_red = outermost_cells(analysis.domain) & reaches + if not rim_red.any(): + closed = True # 최외곽이 전부 파랑 — 바깥 물은 도로로 오지 않는다 + break + + grown_spec, grown_domain, grown_rim = _grow_for_rim( + current, analysis.domain, rim_red, band_cells + ) + widened = grown_domain | _dilate_by(grown_rim, band_cells) + added_mask = widened & ~grown_domain + if not added_mask.any(): + closed = True + break + + logger.info( + "배수유역: %d회차 확장 — 최외곽 적색 %d셀 주변 %.0fm, 셀 %d개 추가", + attempt + 1, + int(rim_red.sum()), + band_m, + int(added_mask.sum()), + ) + widened_analysis = analyze_domain( + grown_spec, + widened, + contour_features, + route_line, + upstream_streams, + elevation_floor_m, + # 격자가 그대로면 방향장을 재사용한다 — 등고선 기하가 안 바뀌었으므로 결과는 같다. + descent=analysis.descent if grown_spec == current else None, + ) + if widened_analysis is None: + break + analysis = widened_analysis + rounds += 1 + + added_reaches = widened_analysis.flow.reaches_road.reshape( + grown_spec.n_rows, grown_spec.n_cols + ) + if not (added_mask & added_reaches).any(): + closed = True # 새로 추가한 셀에 적색이 없다 — 여기까지가 유역이다 + logger.info("배수유역: 새로 추가한 셀에 적색이 없어 확장을 멈춥니다.") + break + else: + logger.warning("배수유역: 확장 상한(%d회)에 도달했습니다.", max_rounds) + + added = int(analysis.domain.sum()) - started_cells + logger.info( + "배수유역: 확장 %d회, 셀 %d → %d (+%d), %s", + rounds, + started_cells, + int(analysis.domain.sum()), + added, + "닫힘" if closed else "미닫힘", + ) + return RedExpansion(analysis=analysis, rounds=rounds, closed=closed, added_cells=added) + + +def _dilate_by(mask: np.ndarray, steps: int) -> np.ndarray: + """8이웃 팽창을 `steps`회 반복한다(정사각 커널이라 반경 = steps 셀).""" + rows, cols = mask.shape + result = mask + for _ in range(steps): + padded = np.zeros((rows + 2, cols + 2), dtype=bool) + padded[1:-1, 1:-1] = result + grown = np.zeros_like(result) + for row_shift in (0, 1, 2): + for col_shift in (0, 1, 2): + grown |= padded[row_shift : row_shift + rows, col_shift : col_shift + cols] + result = grown + return result + + +def _pad_spec(spec: GridSpec, domain: np.ndarray, cells: int) -> tuple[GridSpec, np.ndarray]: + """격자에 사방 여유를 두고 해석 영역 마스크를 그 안으로 옮겨 담는다.""" + if cells <= 0: + return spec, domain + padded_spec = GridSpec( + x_min=spec.x_min - cells * spec.cell_m, + y_max=spec.y_max + cells * spec.cell_m, + cell_m=spec.cell_m, + n_rows=spec.n_rows + 2 * cells, + n_cols=spec.n_cols + 2 * cells, + ) + padded = np.zeros((padded_spec.n_rows, padded_spec.n_cols), dtype=bool) + padded[cells : cells + spec.n_rows, cells : cells + spec.n_cols] = domain + return padded_spec, padded + + +def _grow_for_rim( + spec: GridSpec, domain: np.ndarray, rim: np.ndarray, band_cells: int +) -> tuple[GridSpec, np.ndarray, np.ndarray]: + """적색 최외곽이 격자 bbox에 닿았으면 그 방향으로 격자를 넓히고 마스크를 옮겨 담는다. + + 격자는 셀 정수배로만 넓히므로 도로 시작점 기준 격자점이 그대로 유지된다. + """ + north = band_cells if rim[0, :].any() else 0 + south = band_cells if rim[-1, :].any() else 0 + west = band_cells if rim[:, 0].any() else 0 + east = band_cells if rim[:, -1].any() else 0 + if not (north or south or west or east): + return spec, domain, rim + + grown = GridSpec( + x_min=spec.x_min - west * spec.cell_m, + y_max=spec.y_max + north * spec.cell_m, + cell_m=spec.cell_m, + n_rows=spec.n_rows + north + south, + n_cols=spec.n_cols + west + east, + ) + new_domain = np.zeros((grown.n_rows, grown.n_cols), dtype=bool) + new_rim = np.zeros_like(new_domain) + new_domain[north : north + spec.n_rows, west : west + spec.n_cols] = domain + new_rim[north : north + spec.n_rows, west : west + spec.n_cols] = rim + logger.info( + "배수유역: 격자 확대 %d×%d → %d×%d (북%d 남%d 서%d 동%d 셀)", + spec.n_rows, + spec.n_cols, + grown.n_rows, + grown.n_cols, + north, + south, + west, + east, + ) + return grown, new_domain, new_rim diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Export.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Export.py new file mode 100644 index 00000000..9e4c5e17 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Export.py @@ -0,0 +1,191 @@ +"""배수유역 단계별 검증 산출물을 영구저장소에 남긴다. + +기능을 하나씩 붙일 때마다 그 단계의 결과를 파일로 남겨 사람이 QGIS 등으로 직접 열어 +대조할 수 있게 하는 것이 목적이다(2026-07-31 사용자 지시). 새 단계를 추가할 때는 +`STAGES`에 이름을 하나 더 넣고 `write_stage()`를 호출하면 된다 — 파일명 규칙과 매니페스트 +갱신은 여기서 일괄로 처리한다. + +저장 위치: `storage/{회사}/{사용자}/{프로젝트}/B04_wf1_Surface/drainage/` + - `{단계번호}_{단계이름}.geojson` — WGS84 FeatureCollection, 피처마다 `kind` 속성 + - `manifest.json` — 지금까지 남긴 단계 목록과 요약값 +""" + +from __future__ import annotations + +import json +import logging +from collections.abc import Sequence +from datetime import datetime +from pathlib import Path +from typing import Any, Callable + +import numpy as np +from shapely.geometry.base import BaseGeometry + +from common_util.common_util_storage import resolve_stored_project_path +from config.config_system import DRAINAGE_CACHE_DIRNAME + +logger = logging.getLogger(__name__) + +# 단계 이름 → 파일 접두 번호. 순서대로 읽으면 파이프라인 진행 순서가 된다. +STAGES: dict[str, str] = { + "primary_region": "01", + "flow_direction": "02", + # B05가 읽어 세부유역을 나누는 데 필요한 최소 배열·기하. 화살표·표고는 넣지 않는다. + "road_routing": "03", +} + +_MANIFEST_FILENAME = "manifest.json" +LonLat = Callable[[float, float], tuple[float, float]] + + +def drainage_dir(stored_path: str) -> Path: + return ( + Path(resolve_stored_project_path(stored_path)) / "B04_wf1_Surface" / DRAINAGE_CACHE_DIRNAME + ) + + +def write_stage( + stored_path: str, + stage: str, + layers: dict[str, Sequence[BaseGeometry | tuple[BaseGeometry, dict[str, Any]]]], + properties: dict[str, Any], + to_lonlat: LonLat, +) -> str | None: + """한 단계의 기하 산출물을 GeoJSON으로 저장하고 매니페스트를 갱신한다. + + `layers`는 {레이어이름: 사업지 CRS(m) 기하 목록}이며 레이어 이름이 피처 `kind` 속성이 + 된다. 기하 대신 `(기하, 속성dict)` 짝을 넣으면 그 속성이 피처에 함께 실린다. + 좌표는 여기서 WGS84로 바꾼다 — 저장 파일은 어떤 도구로 열어도 바로 보여야 한다. + """ + prefix = STAGES.get(stage) + if prefix is None: + logger.warning("배수유역: 등록되지 않은 저장 단계 '%s' — 저장을 건너뜁니다.", stage) + return None + + features: list[dict[str, Any]] = [] + counts: dict[str, int] = {} + for kind, entries in layers.items(): + for index, entry in enumerate(entries): + # 항목은 기하 하나이거나 (기하, 속성) 짝이다 — 관 누가거리처럼 붙일 값이 있을 때 쓴다. + geometry, extra = entry if isinstance(entry, tuple) else (entry, None) + feature = _to_feature(kind, index, geometry, to_lonlat, extra) + if feature is not None: + features.append(feature) + counts[kind] = len(entries) + + filename = f"{prefix}_{stage}.geojson" + directory = drainage_dir(stored_path) + target = directory / filename + document = { + "type": "FeatureCollection", + "crs": {"type": "name", "properties": {"name": "urn:ogc:def:crs:OGC:1.3:CRS84"}}, + "properties": {**properties, "counts": counts}, + "features": features, + } + try: + directory.mkdir(parents=True, exist_ok=True) + with target.open("w", encoding="utf-8") as file: + json.dump(document, file, ensure_ascii=False) + except OSError: + logger.warning("배수유역: %s 저장 실패 (%s)", stage, target) + return None + + _update_manifest(directory, stage, filename, {**properties, "counts": counts}) + logger.info("배수유역: %s 저장 — %s (피처 %d개)", stage, target, len(features)) + return str(target) + + +def _to_feature( + kind: str, + index: int, + geometry: BaseGeometry, + to_lonlat: LonLat, + extra: dict[str, Any] | None = None, +) -> dict[str, Any] | None: + coordinates = _to_lonlat_coords(geometry, to_lonlat) + if coordinates is None: + return None + return { + "type": "Feature", + "properties": {"kind": kind, "index": index, **(extra or {})}, + "geometry": {"type": geometry.geom_type, "coordinates": coordinates}, + } + + +def _to_lonlat_coords(geometry: BaseGeometry, to_lonlat: LonLat) -> Any: + """shapely 기하를 WGS84 GeoJSON 좌표 배열로 바꾼다.""" + if geometry.is_empty: + return None + kind = geometry.geom_type + if kind == "Point": + return list(to_lonlat(geometry.x, geometry.y)) + if kind == "LineString": + return [list(to_lonlat(x, y)) for x, y in geometry.coords] + if kind == "Polygon": + return [ + [list(to_lonlat(x, y)) for x, y in ring.coords] + for ring in (geometry.exterior, *geometry.interiors) + ] + if kind in {"MultiPoint", "MultiLineString", "MultiPolygon", "GeometryCollection"}: + parts = [_to_lonlat_coords(part, to_lonlat) for part in geometry.geoms] + return [part for part in parts if part is not None] + return None + + +def write_grid_arrays( + stored_path: str, stage: str, spec: Any, arrays: dict[str, Any], summary: dict[str, Any] +) -> str | None: + """격자 크기의 배열들을 `.npz`로 남긴다(셀 마스크·흐름 방향·도달 여부 등). + + 셀이 수십만 개라 GeoJSON 폴리곤으로는 못 남긴다. 격자 원점·셀 크기와 배열만 저장하면 + 어느 셀이 어떤 값이었는지 그대로 복원된다. 요약값은 manifest에도 기록한다. + """ + prefix = STAGES.get(stage) + if prefix is None or not arrays: + return None + directory = drainage_dir(stored_path) + target = directory / f"{prefix}_{stage}.npz" + try: + directory.mkdir(parents=True, exist_ok=True) + np.savez_compressed( + target, + x_min=spec.x_min, + y_max=spec.y_max, + cell_m=spec.cell_m, + n_rows=spec.n_rows, + n_cols=spec.n_cols, + **arrays, + ) + except OSError: + logger.warning("배수유역: %s 배열 저장 실패 (%s)", stage, target) + return None + _update_manifest(directory, f"{stage}_arrays", target.name, summary) + logger.info("배수유역: %s 배열 저장 — %s (%s)", stage, target, ", ".join(arrays)) + return str(target) + + +def _update_manifest( + directory: Path, stage: str, filename: str, properties: dict[str, Any] +) -> None: + """지금까지 남긴 단계 목록을 한 파일에 모아 둔다 — 무엇이 저장돼 있는지 한눈에 본다.""" + manifest_path = directory / _MANIFEST_FILENAME + manifest: dict[str, Any] = {} + if manifest_path.exists(): + try: + with manifest_path.open("r", encoding="utf-8") as file: + loaded = json.load(file) + if isinstance(loaded, dict): + manifest = loaded + except (OSError, json.JSONDecodeError): + logger.warning("배수유역: manifest를 읽지 못해 새로 만듭니다 (%s).", manifest_path) + manifest[stage] = { + "file": filename, + "saved_at": datetime.now().isoformat(timespec="seconds"), + "properties": properties, + } + try: + with manifest_path.open("w", encoding="utf-8") as file: + json.dump(manifest, file, ensure_ascii=False, indent=2) + except OSError: + logger.warning("배수유역: manifest 저장 실패 (%s).", manifest_path) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Flow.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Flow.py new file mode 100644 index 00000000..91ec8f0b --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Flow.py @@ -0,0 +1,574 @@ +"""배수유역 흐름 해석 — 도로 굽기 · 상류 추적(포인터 더블링) · 유역 폴리곤화. + +핵심은 하나다: **물길을 따라가 도로에 닿는 셀만 유역이다.** +셀마다 D8 수신 셀을 따라가 종착점(root)을 구하고, 그 종착점이 도로 셀이면 활성이다. + +이 방식은 능선을 따로 찾지 않는다. 능선 너머 셀의 물은 다른 계곡으로 빠져 도로에 +닿지 못하므로 자동으로 비활성이 되고, 그 경계선이 곧 능선이다. 유역 안쪽 봉우리는 +물이 결국 도로로 흘러 자동으로 포함된다. + +종착점은 세부유역 라벨의 근거로도 그대로 쓴다 — 셀이 도달한 도로 셀이 정해지면 +그 도로 셀을 담당하는 관이 곧 그 셀의 유역 번호다. 관 배치가 바뀌어도 격자 해석을 +다시 돌릴 필요 없이 "도로 셀 → 관" 대응만 다시 계산하면 된다. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any + +import numpy as np +from rasterio.features import rasterize, shapes +from scipy.spatial import cKDTree +from shapely.geometry import LineString, MultiPolygon, Polygon, shape +from shapely.ops import unary_union + +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import ( + AZIMUTH_STEPS, + ContourCloud, + GridSpec, + TerrainGrid, + build_cell_mask, + build_terrain_grid, + descent_azimuth, + expand_grid_spec, + grid_transform, +) +from config.config_system import ( + DRAINAGE_EXPAND_STEP_M, + DRAINAGE_MAX_EXPAND_ROUNDS, + DRAINAGE_MIN_BASIN_AREA_M2, + DRAINAGE_POLYGON_SIMPLIFY_M, + DRAINAGE_ROAD_WIDTH_M, +) + +logger = logging.getLogger(__name__) + +# 포인터 더블링 반복 상한. 한 번에 경로 길이가 2배가 되므로 2^40 스텝이면 어떤 격자도 덮는다. +_MAX_DOUBLING_ROUNDS = 40 + +# 8이웃 (행 증분, 열 증분) — 최외곽 판정용. 거리는 쓰지 않는다. +_NEIGHBOR_SHIFTS = ( + (-1, 0, 1.0), + (1, 0, 1.0), + (0, -1, 1.0), + (0, 1, 1.0), + (-1, -1, 1.0), + (-1, 1, 1.0), + (1, -1, 1.0), + (1, 1, 1.0), +) + + +@dataclass +class RoadRaster: + """격자에 구운 도로. 도로 셀은 흐름을 흡수하는 종착점이 된다.""" + + mask: np.ndarray # (R, C) bool + cell_index: np.ndarray # (K,) int32 — 도로 셀의 평탄 인덱스 + chainage: np.ndarray # (K,) float64 — 도로 셀의 누가거리(m) + slot_of_cell: np.ndarray # (R*C,) int32 — 도로 셀이면 K 내 위치, 아니면 −1 + + @property + def count(self) -> int: + return int(self.cell_index.size) + + +@dataclass +class FlowResult: + """상류 추적 결과.""" + + root: np.ndarray # (R*C,) int32 — 흐름 종착 셀의 평탄 인덱스 + road_slot: np.ndarray # (R*C,) int32 — 도달한 도로 셀 슬롯, 도달 못하면 −1 + active: np.ndarray # (R, C) bool — 도로에 물이 닿는 셀 + path_length: np.ndarray # (R*C,) float32 — 종착점까지 물길 길이(m) + strength: np.ndarray # (K,) int64 — 도로 셀별 상류 셀 수(흐름 강도) + + +# ── 도로 굽기 ─────────────────────────────────────────────────────────────── + + +def rasterize_road( + spec: GridSpec, + route_line: LineString, + width_m: float = DRAINAGE_ROAD_WIDTH_M, +) -> RoadRaster: + """노선을 노폭만큼 두껍게 격자에 굽고, 각 도로 셀에 누가거리를 붙인다. + + 폭을 주는 이유는 실제 노면이 물을 받기 때문이기도 하지만, 1셀 선으로 구우면 D8 + 대각 이동이 도로를 건너뛰어 상류 물이 도로를 지나쳐 버리기 때문이다. 3셀 이상 두께면 + 내리막 물길이 반드시 도로 셀을 한 번은 밟는다. + """ + half_width = max(width_m / 2.0, spec.cell_m) + burned = rasterize( + [(route_line.buffer(half_width), 1)], + out_shape=(spec.n_rows, spec.n_cols), + transform=grid_transform(spec), + fill=0, + dtype="uint8", + all_touched=True, + ).astype(bool) + + slot_of_cell = np.full(spec.size, -1, dtype=np.int32) + cell_index = np.flatnonzero(burned.reshape(-1)).astype(np.int32) + if cell_index.size == 0: + logger.warning("배수유역: 노선이 격자 범위 밖입니다 — 도로 셀 0개.") + return RoadRaster(burned, cell_index, np.zeros(0), slot_of_cell) + + # 도로 셀 누가거리는 노선을 촘촘히 샘플링해 가장 가까운 샘플의 누가거리로 준다. + step = max(spec.cell_m / 2.0, 0.25) + positions = np.arange(0.0, route_line.length + step, step) + samples = np.array([list(route_line.interpolate(p).coords)[0] for p in positions]) + rows = (cell_index // spec.n_cols).astype(np.float64) + cols = (cell_index % spec.n_cols).astype(np.float64) + centers = np.column_stack( + ( + spec.x_min + (cols + 0.5) * spec.cell_m, + spec.y_max - (rows + 0.5) * spec.cell_m, + ) + ) + _, nearest = cKDTree(samples).query(centers) + chainage = np.minimum(positions[nearest], route_line.length) + slot_of_cell[cell_index] = np.arange(cell_index.size, dtype=np.int32) + return RoadRaster(burned, cell_index, chainage, slot_of_cell) + + +# ── 상류 추적 ─────────────────────────────────────────────────────────────── + + +def trace_flow(terrain: TerrainGrid, road: RoadRaster) -> FlowResult: + """모든 셀의 물길 종착점을 구하고 도로 도달 여부(=유역 포함 여부)를 판정한다. + + 포인터 더블링으로 한 번에 경로 길이를 2배씩 늘려 종착점을 찾는다. 채움·평탄해소를 + 거친 표고에서는 흐름을 따라 표고가 단조 감소하므로 순환이 없고, 반복은 항상 끝난다. + """ + spec = terrain.spec + receiver = terrain.receiver.astype(np.int32, copy=True) + step_length = terrain.step_length.astype(np.float32, copy=True) + + # 도로 셀은 흐름을 흡수한다 — 물이 도로에 닿으면 거기서 끝난다. + receiver[road.cell_index] = road.cell_index + step_length[road.cell_index] = 0.0 + + jump = receiver + path_length = step_length + for _ in range(_MAX_DOUBLING_ROUNDS): + next_jump = jump[jump] + if np.array_equal(next_jump, jump): + break + path_length = path_length + path_length[jump] + jump = next_jump + + road_slot = road.slot_of_cell[jump] + active_flat = road_slot >= 0 + strength = ( + np.bincount(road_slot[active_flat], minlength=max(road.count, 1)).astype(np.int64) + if road.count + else np.zeros(0, dtype=np.int64) + ) + logger.info( + "배수유역: 활성 셀 %d / %d (도로 셀 %d)", int(active_flat.sum()), spec.size, road.count + ) + return FlowResult( + root=jump, + road_slot=road_slot, + active=active_flat.reshape(spec.n_rows, spec.n_cols), + path_length=path_length, + strength=strength, + ) + + +def burn_stream_flow( + terrain: TerrainGrid, road: RoadRaster, streams: list[LineString] +) -> tuple[TerrainGrid, np.ndarray]: + """확정된 상류 세류망을 따라 격자 흐름 방향을 강제로 새긴다. + + 등고선 TIN 보간면은 실제 물골(thalweg)을 그대로 재현하지 못한다. 그래서 세류선 위 + 셀인데도 D8이 옆 사면으로 흘려보내 도로에 닿지 못하는 일이 생긴다. 세류선은 이미 + "도로를 건너 하류로 빠지는 물길"로 확정된 자료이므로, 그 위 셀의 흐름 방향은 추정할 + 것이 아니라 **그대로 따라야 한다**(2026-07-31 사용자 지시). + + 세류선 위 셀은 물길 방향의 다음 셀을 수신 셀로 삼는다. 그러면 세류선 셀은 물론, + 세류선으로 흘러드는 사면 셀까지 전부 도로에 도달한다. 도로 셀은 흡수점이므로 수신 셀은 + 건드리지 않되, 세류 셀 목록에는 포함한다. + + **표고 유무를 따지지 않는다.** 세류선은 확정된 자료이므로 등고선 TIN 껍질 밖이라 + 표고가 없는 셀이라도 물이 지나간다는 사실은 변하지 않는다. 표고를 조건으로 걸면 그런 + 셀이 새김에서 빠져 파랑·회색으로 남는다. + + 돌려주는 값: (흐름이 새겨진 지형, 세류선이 지나는 셀 마스크). + """ + spec = terrain.spec + receiver = terrain.receiver.copy() + step_length = terrain.step_length.copy() + road_cells = road.mask.reshape(-1) + burned = np.zeros(spec.size, dtype=bool) + + tails: list[int] = [] + # 셀이 몇 갈래에 속하는지 — 하류 끝이 합류 지점인지 판단하는 근거. + visits = np.zeros(spec.size, dtype=np.int32) + for line in streams: + chain = _line_cell_chain(spec, line) + if not chain: + continue + # 사슬의 **모든** 셀을 세류 셀로 표시한다 — 마지막 셀도 물길 위다. + burned[chain] = True + np.add.at(visits, np.unique(chain), 1) + for current, following in zip(chain, chain[1:]): + if road_cells[current] or not _is_neighbour(spec, current, following): + continue + receiver[current] = following + step_length[current] = _cell_distance(spec, current, following) + tails.append(chain[-1]) + + # 하류 끝이 도로에도 닿지 않고 다른 갈래와도 겹치지 않으면 그 갈래는 떠 있는 것이다. + # 지류가 본류 중간에 합류하는 경우는 끝 셀을 두 갈래가 공유하므로 정상이다. + detached = sum(1 for tail in tails if not road_cells[tail] and visits[tail] < 2) + if detached: + logger.warning( + "배수유역: 세류망 %d갈래의 하류 끝이 도로·다른 세류 어디에도 닿지 않습니다.", detached + ) + logger.info("배수유역: 세류망 셀 %d개 표시 (세류 %d갈래)", int(burned.sum()), len(streams)) + return ( + TerrainGrid( + spec=spec, + elevation=terrain.elevation, + valid=terrain.valid, + receiver=receiver, + step_length=step_length, + ), + burned, + ) + + +def _line_cell_chain(spec: GridSpec, line: LineString) -> list[int]: + """선을 따라 지나가는 셀을 순서대로 뽑는다(연속 중복 제거).""" + step = max(spec.cell_m / 2.0, 0.1) + positions = np.arange(0.0, line.length + step, step) + chain: list[int] = [] + for position in positions: + point = line.interpolate(float(position)) + col = int((point.x - spec.x_min) // spec.cell_m) + row = int((spec.y_max - point.y) // spec.cell_m) + if not (0 <= row < spec.n_rows and 0 <= col < spec.n_cols): + continue + index = row * spec.n_cols + col + if not chain or chain[-1] != index: + chain.append(index) + return chain + + +def _is_neighbour(spec: GridSpec, first: int, second: int) -> bool: + row_delta = abs(first // spec.n_cols - second // spec.n_cols) + col_delta = abs(first % spec.n_cols - second % spec.n_cols) + return max(row_delta, col_delta) == 1 + + +def _cell_distance(spec: GridSpec, first: int, second: int) -> float: + row_delta = abs(first // spec.n_cols - second // spec.n_cols) + col_delta = abs(first % spec.n_cols - second % spec.n_cols) + return spec.cell_m * float(np.hypot(row_delta, col_delta)) + + +@dataclass +class FlowClassification: + """셀별 흐름 방향과 도로 도달 여부 — 확장 없이 현재 격자만 본 결과.""" + + direction: np.ndarray # (R*C,) int16 — 32방위 코드(32=제자리, 33=무효) + reaches_road: np.ndarray # (R*C,) bool — 물길을 따라가면 도로에 닿는가 + analyzed: np.ndarray # (R*C,) bool — 실제로 판정한 셀 + outer_seeds: int # 최외곽에서 출발해 판정한 셀 수 + interior_seeds: int # 최외곽 추적에 안 걸려 따로 출발시킨 내부 셀 수 + burned: np.ndarray | None = None # (R*C,) bool — 세류망을 따라 흐름을 새긴 셀 + + +def outermost_cells(domain: np.ndarray) -> np.ndarray: + """해석 영역의 최외곽 셀 — 영역 밖(또는 격자 밖)에 8이웃이 하나라도 닿는 셀.""" + padded = np.zeros((domain.shape[0] + 2, domain.shape[1] + 2), dtype=bool) + padded[1:-1, 1:-1] = domain + exposed = np.zeros_like(domain) + rows, cols = domain.shape + for row_shift, col_shift, _ in _NEIGHBOR_SHIFTS: + neighbour = padded[ + 1 + row_shift : 1 + row_shift + rows, 1 + col_shift : 1 + col_shift + cols + ] + exposed |= ~neighbour + return exposed & domain + + +def classify_flow( + terrain: TerrainGrid, + road: RoadRaster, + burned: np.ndarray | None = None, + azimuth: np.ndarray | None = None, +) -> FlowClassification: + """최외곽 셀부터 물길을 따라가며 도로 도달 여부를 판정한다. + + 물이 흐르는 순서 그대로 따라간다 — 셀이 각자 도로를 바라보는 게 아니라, 한 셀에서 + 출발해 화살표가 가리키는 다음 셀, 그 셀이 가리키는 그다음 셀로 사슬처럼 이어 간다 + (2026-07-31 사용자 지시). + + 판정 순서는 다음과 같다(2026-07-31 사용자 지시): + + ⓪ **세류선과 겹치는 셀을 먼저 적색으로 확정한다.** 세류망은 이미 "도로를 건너 하류로 + 빠지는 물길"로 확정된 자료다. 표고가 있든 없든 물이 지나간다는 사실은 변하지 않으므로 + 추적 결과를 기다릴 이유가 없다. + ① 최외곽 셀에서 출발해 사슬을 따라간다. + ② 사슬이 도로 셀이나 세류 셀에 닿으면 사슬 전체가 적색, 싱크에서 멈추거나 해석 영역 + 밖으로 나가면 전체가 미도달(파랑 채움 + 백색 화살표)이다. + ③ 이미 색이 정해진 셀을 만나면 **그 셀의 색을 그대로 물려받고** 끝낸다. 판정된 셀은 + 다시 분석하지 않는다. + ④ 최외곽 추적에 안 걸린 내부 셀을 그다음에 따로 출발시킨다. + + `azimuth`를 주면 그 32방위 코드를 그대로 화살표로 쓴다(등고선 하강 방향). 주지 않으면 + 지표면 기울기에서 뽑는다. **화살표는 실제 수신 셀과 같은 방향이어야 한다** — 어긋나면 + 화살표로 사슬을 따라가는 눈 검증이 성립하지 않는다. + """ + spec = terrain.spec + valid = terrain.valid.reshape(-1) + receiver = terrain.receiver + # 도로 셀과 세류망 셀 둘 다 "여기 닿으면 적색"인 종결점이다. + stream_cells = np.zeros(spec.size, dtype=bool) if burned is None else burned + absorbing = (road.mask.reshape(-1) & valid) | stream_cells + + if azimuth is None: + direction = descent_azimuth(spec, terrain.elevation, terrain.valid, receiver, burned) + else: + direction = _azimuth_with_burn(spec, azimuth, receiver, burned) + reaches = np.zeros(spec.size, dtype=bool) + # 0=미방문, 1=경로에 올라 있음, 2=판정 완료 + state = np.zeros(spec.size, dtype=np.int8) + + # ⓪ 세류선과 겹치는 셀을 먼저 적색으로 못박는다. + reaches[stream_cells] = True + state[stream_cells] = 2 + stream_done = int(stream_cells.sum()) + + outer = np.flatnonzero(outermost_cells(terrain.valid)) + remaining = np.flatnonzero(valid) + outer_done = _walk_from(outer, receiver, valid, absorbing, state, reaches) + interior_done = _walk_from(remaining, receiver, valid, absorbing, state, reaches) + + analyzed = state == 2 + logger.info( + "배수유역: 흐름 판정 %d셀 (세류 선확정 %d / 최외곽 출발 %d / 내부 보충 %d) — " + "도로 도달 %d, 미도달 %d", + int(analyzed.sum()), + stream_done, + outer_done, + interior_done, + int((reaches & analyzed).sum()), + int((~reaches & analyzed).sum()), + ) + return FlowClassification( + direction=direction, + reaches_road=reaches, + analyzed=analyzed, + outer_seeds=outer_done, + interior_seeds=interior_done, + burned=burned, + ) + + +def _azimuth_with_burn( + spec: GridSpec, azimuth: np.ndarray, receiver: np.ndarray, burned: np.ndarray | None +) -> np.ndarray: + """세류망을 따라 흐름을 새긴 셀은 그 수신 셀 방향으로 화살표를 덮어쓴다.""" + direction = azimuth.astype(np.int16, copy=True) + if burned is None or not burned.any(): + return direction + index = np.arange(spec.size, dtype=np.int64) + moved = burned & (receiver != index) + if not moved.any(): + return direction + row_delta = (receiver[moved] // spec.n_cols - index[moved] // spec.n_cols).astype(np.float64) + col_delta = (receiver[moved] % spec.n_cols - index[moved] % spec.n_cols).astype(np.float64) + angle = np.arctan2(row_delta, col_delta) + direction[moved] = ( + np.rint(angle / (2.0 * np.pi / AZIMUTH_STEPS)).astype(np.int16) % AZIMUTH_STEPS + ) + return direction + + +def _walk_from( + starts: np.ndarray, + receiver: np.ndarray, + valid: np.ndarray, + absorbing: np.ndarray, + state: np.ndarray, + reaches: np.ndarray, +) -> int: + """출발 셀에서 물길 사슬을 따라가며 판정하고, 사슬 전체에 같은 색을 적는다. + + `absorbing`은 도로 셀과 확정된 세류망 셀 — 여기 닿으면 사슬 전체가 도로 도달이다. + 새로 판정한 셀 수를 돌려준다. + """ + resolved = 0 + path: list[int] = [] + for start in starts.tolist(): + if state[start] == 2: + continue + path.clear() + node = start + while True: + if state[node] == 2: + verdict = bool(reaches[node]) # 이미 색이 정해진 셀 — 그 색을 물려받는다 + break + if state[node] == 1: # 방어: 채움·평탄해소 후에는 순환이 없어야 한다 + verdict = False + break + state[node] = 1 + path.append(node) + if absorbing[node]: + verdict = True # 도로 또는 세류망에 합류 — 여기서 하류로 빠진다 + break + following = int(receiver[node]) + if following == node: + verdict = False # 싱크에 갇힘 + break + if not valid[following] and state[following] != 2: + # 해석 영역 밖으로 빠짐. 단, 이미 색이 정해진 셀(세류 선확정 등)이면 따라간다. + verdict = False + break + node = following + for visited in path: + reaches[visited] = verdict + state[visited] = 2 + resolved += len(path) + return resolved + + +# ── 격자 확장 (별도 단계) ─────────────────────────────────────────────────── + + +@dataclass +class ExpansionResult: + """확장 루프 결과. 확장을 쓰지 않는 경로에서는 이 모듈을 부르지 않는다.""" + + spec: GridSpec + terrain: TerrainGrid + road: RoadRaster + flow: FlowResult + rounds: int # 실제로 넓힌 횟수 (0 = 처음부터 닫혀 있었음) + closed: bool # 경계 링이 전부 비활성이 되어 스스로 멈췄는가 + + +def expand_until_closed( + spec: GridSpec, + cloud: ContourCloud, + route_line: LineString, + region_area: Any = None, + road_width_m: float = DRAINAGE_ROAD_WIDTH_M, + max_rounds: int = DRAINAGE_MAX_EXPAND_ROUNDS, + step_m: float = DRAINAGE_EXPAND_STEP_M, +) -> ExpansionResult: + """활성 셀이 격자 최외곽에 닿은 방향으로만 넓히며 유역이 닫힐 때까지 반복한다. + + **본 계산 경로에서만 쓰는 별도 단계다**(2026-07-31 사용자 지시로 분리). 단계 검증 + 미리보기는 확장 없이 현재 격자만 본다 — 확장 로직 자체가 아직 검증 대상이기 때문이다. + + 종료 조건은 반경 상한이 아니라 **경계 링 전체가 비활성**이 되는 것이다. 비활성 셀이 + 최외곽에 띠로 완성되면 그 바깥은 볼 필요가 없다. `max_rounds`는 무한 반복 방지용이다. + + `region_area`를 주면 확장된 격자에서도 그 영역에 걸치는 셀만 해석 대상으로 삼는다. + """ + terrain = road = flow = None + rounds = 0 + closed = False + for attempt in range(max_rounds + 1): + domain = build_cell_mask(spec, region_area) if region_area is not None else None + terrain = build_terrain_grid(spec, cloud, domain) + road = rasterize_road(spec, route_line, road_width_m) + flow = trace_flow(terrain, road) + contact = border_contact(flow.active) + if not any(contact.values()): + closed = True + break + if attempt == max_rounds: + logger.warning( + "배수유역: 확장 상한(%d회) 도달 — 경계 %s가 아직 활성입니다.", + max_rounds, + [side for side, touched in contact.items() if touched], + ) + break + widened = expand_grid_spec(spec, contact, step_m) + if widened == spec: + break + logger.info( + "배수유역: 경계 %s 활성 — %.0fm 확장 (%d회차)", + [side for side, touched in contact.items() if touched], + step_m, + attempt + 1, + ) + spec = widened + rounds += 1 + + assert terrain is not None and road is not None and flow is not None + return ExpansionResult( + spec=spec, terrain=terrain, road=road, flow=flow, rounds=rounds, closed=closed + ) + + +def border_contact(active: np.ndarray) -> dict[str, bool]: + """활성 셀이 격자 최외곽에 닿은 방향. 전부 False면 유역이 능선 안에서 닫힌 것이다.""" + return { + "north": bool(active[0, :].any()), + "south": bool(active[-1, :].any()), + "west": bool(active[:, 0].any()), + "east": bool(active[:, -1].any()), + } + + +# ── 폴리곤화 ──────────────────────────────────────────────────────────────── + + +def polygonize_labels( + spec: GridSpec, + labels: np.ndarray, + min_area_m2: float = DRAINAGE_MIN_BASIN_AREA_M2, +) -> dict[int, Polygon | MultiPolygon]: + """라벨 격자를 라벨별 폴리곤으로 바꾼다. 음수 라벨은 배경으로 무시한다.""" + label_grid = np.ascontiguousarray(labels.reshape(spec.n_rows, spec.n_cols), dtype=np.int32) + valid_mask = label_grid >= 0 + if not valid_mask.any(): + return {} + collected: dict[int, list[Polygon]] = {} + for geometry, value in shapes( + label_grid, mask=valid_mask, transform=grid_transform(spec), connectivity=4 + ): + polygon = shape(geometry) + if polygon.is_empty or polygon.area < min_area_m2: + continue + collected.setdefault(int(value), []).append(polygon) + + merged: dict[int, Polygon | MultiPolygon] = {} + for label, parts in collected.items(): + union = unary_union(parts) + if union.is_empty: + continue + simplified = union.simplify(DRAINAGE_POLYGON_SIMPLIFY_M, preserve_topology=True) + merged[label] = simplified if not simplified.is_empty else union + return merged + + +def largest_ring(geometry: Polygon | MultiPolygon) -> list[tuple[float, float]]: + """폴리곤(또는 멀티폴리곤)에서 가장 큰 조각의 외곽 링 좌표를 뽑는다.""" + if geometry.is_empty: + return [] + if geometry.geom_type == "MultiPolygon": + geometry = max(geometry.geoms, key=lambda part: part.area) + return [(float(x), float(y)) for x, y in geometry.exterior.coords] + + +def outer_boundary( + spec: GridSpec, active: np.ndarray, min_area_m2: float = DRAINAGE_MIN_BASIN_AREA_M2 +) -> Polygon | MultiPolygon | None: + """활성 셀 전체의 외곽 = 2차 전체 배수유역 경계. + + 비활성 셀이 격자 최외곽에 띠로 완성되면 활성 영역이 그 안에 닫힌다. 그 닫힌 영역의 + 바깥선이 곧 분수령이므로 능선을 따로 그릴 필요가 없다. + """ + labels = np.where(active.reshape(-1), 0, -1).astype(np.int32) + polygons = polygonize_labels(spec, labels, min_area_m2) + return polygons.get(0) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Grid.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Grid.py new file mode 100644 index 00000000..1bad541d --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Grid.py @@ -0,0 +1,559 @@ +"""배수유역 해석 격자 생성 — 등고선 TIN 보간 · 웅덩이 채움 · D8 물 방향. + +지형 근거는 **도엽 등고선**뿐이다. 라이다 DEM은 노선 주변만 커버해 유역 산정에 필요한 +상류 범위를 담지 못하므로 쓰지 않는다(2026-07-31 사용자 지시). 표고점도 쓰지 않는다. + +처리 순서 + ① 계획선 최저점 아래 등고선·짧은 파편 제거 + ② 세류선을 노선 교차점에서 잘라 상류측만 남김 + ③ 남은 세류선 + 노선을 반경 버퍼한 범위의 bbox로 격자 생성 + ④ 등고선 정점 Delaunay TIN 선형보간으로 셀 표고 산출 + ⑤ 웅덩이 채움(형태학적 재구성) + 평탄면 미세경사 부여 + ⑥ D8(8방향 최급강하) 수신 셀 인덱스 산출 + +⑤가 없으면 등고선 TIN 특유의 가짜 웅덩이·평탄 삼각형에서 흐름이 끊겨 상류 추적이 +도중에 멈춘다. 능선 탐지는 하지 않는다 — 흐름이 도로에 닿는지 여부만으로 유역이 정해진다. +""" + +from __future__ import annotations + +import logging +import math +from dataclasses import dataclass +from typing import Any + +import numpy as np +from affine import Affine +from rasterio.features import rasterize +from rasterio.transform import from_origin +from scipy.interpolate import LinearNDInterpolator +from scipy.ndimage import distance_transform_edt +from shapely import segmentize +from shapely.geometry import LineString, shape +from shapely.geometry.base import BaseGeometry +from skimage.morphology import reconstruction + +from config.config_system import ( + DRAINAGE_CONTOUR_CLIP_MARGIN_M, + DRAINAGE_CONTOUR_MARGIN_M, + DRAINAGE_CONTOUR_MIN_LENGTH_M, + DRAINAGE_CONTOUR_RESAMPLE_M, + DRAINAGE_FLAT_EPSILON_M, + DRAINAGE_MAX_GRID_CELLS, +) + +logger = logging.getLogger(__name__) + +# 표고 속성 키: 도엽 등고선(등고수치)·gpkg 등고선(CTRLN_HG) 통합. +ELEVATION_KEYS = ("등고수치", "CTRLN_HG", "수치", "표고", "높이", "elevation", "ELEV") + +# D8 이웃 (행 증분, 열 증분, 거리계수). 행은 아래로 증가(북 → 남). +_NEIGHBORS = ( + (-1, 0, 1.0), + (1, 0, 1.0), + (0, -1, 1.0), + (0, 1, 1.0), + (-1, -1, math.sqrt(2.0)), + (-1, 1, math.sqrt(2.0)), + (1, -1, math.sqrt(2.0)), + (1, 1, math.sqrt(2.0)), +) + + +@dataclass(frozen=True) +class GridSpec: + """해석 격자 기하. (0,0) 셀 중심이 (x_min + cell/2, y_max − cell/2)에 놓인다.""" + + x_min: float + y_max: float + cell_m: float + n_rows: int + n_cols: int + + @property + def size(self) -> int: + return self.n_rows * self.n_cols + + @property + def cell_area_m2(self) -> float: + return self.cell_m * self.cell_m + + def world_to_rc(self, x: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """세계좌표(m)를 격자 행·열로 바꾼다. 범위를 벗어나면 −1을 돌려준다.""" + col = np.floor((x - self.x_min) / self.cell_m).astype(np.int64) + row = np.floor((self.y_max - y) / self.cell_m).astype(np.int64) + outside = (col < 0) | (col >= self.n_cols) | (row < 0) | (row >= self.n_rows) + col[outside] = -1 + row[outside] = -1 + return row, col + + def cell_centers_x(self) -> np.ndarray: + return self.x_min + (np.arange(self.n_cols, dtype=np.float64) + 0.5) * self.cell_m + + def cell_centers_y(self) -> np.ndarray: + return self.y_max - (np.arange(self.n_rows, dtype=np.float64) + 0.5) * self.cell_m + + +@dataclass +class ContourCloud: + """등고선에서 뽑은 정점 구름. TIN 보간과 세류 상·하류 판정에 함께 쓴다.""" + + xy: np.ndarray # (N, 2) float64 + z: np.ndarray # (N,) float64 + + @property + def is_empty(self) -> bool: + return self.xy.shape[0] < 3 + + +@dataclass +class TerrainGrid: + """격자 지형 해석 결과.""" + + spec: GridSpec + elevation: np.ndarray # (R, C) float32 — 채움·평탄해소 후 표고, 무효 셀은 NaN + valid: np.ndarray # (R, C) bool — 등고선 TIN 내부 여부 + receiver: np.ndarray # (R*C,) int32 — D8 수신 셀의 평탄 인덱스, 싱크는 자기 자신 + step_length: np.ndarray # (R*C,) float32 — 수신 셀까지 거리(m), 싱크는 0 + + +# ── ① 등고선 정리 ──────────────────────────────────────────────────────────── + + +def _feature_elevation(properties: dict[str, Any]) -> float | None: + 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 iter_linestrings(geometry: Any) -> list[LineString]: + if geometry.geom_type == "LineString": + return [geometry] + if geometry.geom_type in {"MultiLineString", "GeometryCollection"}: + lines: list[LineString] = [] + for part in geometry.geoms: + lines.extend(iter_linestrings(part)) + return lines + return [] + + +def build_contour_cloud( + contour_features: list[dict[str, Any]], + elevation_floor_m: float | None = None, + clip_bounds: tuple[float, float, float, float] | None = None, +) -> ContourCloud: + """등고선 피처를 표고가 붙은 정점 구름으로 바꾼다. + + `elevation_floor_m` 아래 등고선은 계획선 최저점보다 낮아 상류 기여가 불가능하므로 + 버린다. 길이가 짧은 파편도 노이즈로 보고 버리되, 임계 이상인 봉우리 폐합 등고선은 + 남긴다(봉우리 표고가 사라지면 그 일대 흐름 방향이 통째로 틀어진다). + + `clip_bounds`(x_min, y_min, x_max, y_max)를 주면 그 밖 등고선은 읽지 않는다. 도엽 + 전체 등고선을 다 물고 가면 TIN 삼각망 비용만 커지고 결과는 같다. + """ + xs: list[np.ndarray] = [] + ys: list[np.ndarray] = [] + zs: list[np.ndarray] = [] + dropped_low = 0 + dropped_short = 0 + dropped_outside = 0 + for feature in contour_features: + geometry = feature.get("geometry") + if not geometry: + continue + elevation = _feature_elevation(feature.get("properties") or {}) + if elevation is None: + continue + if elevation_floor_m is not None and elevation < elevation_floor_m: + dropped_low += 1 + continue + try: + parsed = shape(geometry) + except Exception: # noqa: BLE001 - 손상된 피처는 건너뛴다 + continue + if clip_bounds is not None and _outside_bounds(parsed.bounds, clip_bounds): + dropped_outside += 1 + continue + for line in iter_linestrings(parsed): + if line.length < DRAINAGE_CONTOUR_MIN_LENGTH_M: + dropped_short += 1 + continue + coords = np.asarray(segmentize(line, DRAINAGE_CONTOUR_RESAMPLE_M).coords) + if coords.shape[0] < 2: + continue + xs.append(coords[:, 0]) + ys.append(coords[:, 1]) + zs.append(np.full(coords.shape[0], elevation, dtype=np.float64)) + if not xs: + logger.warning( + "배수유역: 사용할 등고선이 없습니다(저지대 %d, 파편 %d, 범위밖 %d 제외).", + dropped_low, + dropped_short, + dropped_outside, + ) + return ContourCloud(np.zeros((0, 2)), np.zeros(0)) + xy = np.column_stack((np.concatenate(xs), np.concatenate(ys))) + z = np.concatenate(zs) + logger.info( + "배수유역: 등고선 정점 %d개 (저지대 %d, 파편 %d, 범위밖 %d 제외)", + xy.shape[0], + dropped_low, + dropped_short, + dropped_outside, + ) + return ContourCloud(xy, z) + + +def _outside_bounds( + bounds: tuple[float, float, float, float], clip: tuple[float, float, float, float] +) -> bool: + return bounds[2] < clip[0] or bounds[0] > clip[2] or bounds[3] < clip[1] or bounds[1] > clip[3] + + +def grid_spec_from_bounds( + x_min: float, + y_min: float, + x_max: float, + y_max: float, + cell_m: float, + anchor_xy: tuple[float, float] | None = None, +) -> GridSpec: + """범위를 덮는 격자를 만든다. `anchor_xy`를 주면 그 점에 셀 모서리를 맞춘다. + + 격자 원점은 **도로 시작점**에 고정한다(2026-07-31 사용자 지시). bbox 좌상단에 맞추면 + 1차 영역이 조금만 달라져도 격자가 통째로 밀려 이전 결과와 셀이 대응되지 않는다. + 도로 시작점에 맞추면 반경·영역을 바꿔도 같은 자리의 셀은 같은 자리에 남는다. + + 격자 크기는 절대 자동으로 바꾸지 않는다 — config 값이 그대로 쓰인다(사용자 지시). + 셀 수가 많으면 경고만 남기고 그대로 진행한다. + """ + if anchor_xy is not None: + anchor_x, anchor_y = anchor_xy + # 앵커에서 셀 정수배만큼 밖으로 나가 범위를 덮는다(넓어질 뿐 좁아지지 않는다). + x_min = anchor_x - math.ceil((anchor_x - x_min) / cell_m) * cell_m + y_max = anchor_y + math.ceil((y_max - anchor_y) / cell_m) * cell_m + n_cols = max(1, int(math.ceil((x_max - x_min) / cell_m))) + n_rows = max(1, int(math.ceil((y_max - y_min) / cell_m))) + if n_cols * n_rows > DRAINAGE_MAX_GRID_CELLS: + logger.warning( + "배수유역: 격자 %d×%d = %d셀 (%.0fm × %.0fm, 셀 %.2fm) — 권장 상한 %d셀 초과. " + "그대로 진행합니다. 느리면 DRAINAGE_GRID_SIZE_M을 올리세요.", + n_rows, + n_cols, + n_rows * n_cols, + n_cols * cell_m, + n_rows * cell_m, + cell_m, + DRAINAGE_MAX_GRID_CELLS, + ) + return GridSpec(x_min=x_min, y_max=y_max, cell_m=cell_m, n_rows=n_rows, n_cols=n_cols) + + +def expand_grid_spec(spec: GridSpec, sides: dict[str, bool], step_m: float) -> GridSpec: + """활성 셀이 닿은 방향으로만 격자를 넓힌다. + + 셀 정수배로만 넓혀 격자 격자점(도로 시작점 기준)이 그대로 유지되게 한다. + """ + steps = max(1, int(math.ceil(step_m / spec.cell_m))) + west = steps if sides.get("west") else 0 + east = steps if sides.get("east") else 0 + north = steps if sides.get("north") else 0 + south = steps if sides.get("south") else 0 + return GridSpec( + x_min=spec.x_min - west * spec.cell_m, + y_max=spec.y_max + north * spec.cell_m, + cell_m=spec.cell_m, + n_rows=spec.n_rows + north + south, + n_cols=spec.n_cols + west + east, + ) + + +def grid_transform(spec: GridSpec) -> Affine: + """rasterio 아핀 변환. 행 0이 북쪽(y_max)이다.""" + return from_origin(spec.x_min, spec.y_max, spec.cell_m, spec.cell_m) + + +def build_cell_mask(spec: GridSpec, geometry: BaseGeometry) -> np.ndarray: + """영역에 **조금이라도 걸치는** 셀만 True인 (rows, cols) 마스크. + + `all_touched=True`라서 셀이 영역과 한 점만 스쳐도 생성 대상이 된다(사용자 지시). + """ + if geometry is None or geometry.is_empty: + return np.zeros((spec.n_rows, spec.n_cols), dtype=bool) + burned = rasterize( + [(geometry, 1)], + out_shape=(spec.n_rows, spec.n_cols), + transform=grid_transform(spec), + fill=0, + dtype="uint8", + all_touched=True, + ) + return burned.astype(bool) + + +def mask_row_spans(mask: np.ndarray) -> list[tuple[int, int, int]]: + """마스크를 행별 연속 구간 [행, 시작열, 끝열(포함)]으로 압축한다. + + 셀 수십만 개를 그대로 내보낼 수 없으니 구간으로 줄인다 — 프론트는 이 구간만 받아 + 실제 셀 사각형을 그린다. + """ + spans: list[tuple[int, int, int]] = [] + for row in range(mask.shape[0]): + line = mask[row] + if not line.any(): + continue + padded = np.concatenate(([False], line, [False])) + edges = np.flatnonzero(padded[1:] != padded[:-1]) + for start, stop in zip(edges[0::2], edges[1::2]): + spans.append((row, int(start), int(stop) - 1)) + return spans + + +# ── ④ TIN 보간 ────────────────────────────────────────────────────────────── + + +def interpolate_elevation(spec: GridSpec, cloud: ContourCloud) -> np.ndarray: + """등고선 정점 Delaunay TIN으로 셀 표고를 선형보간한다. 외부는 NaN. + + 삼각망 비용은 정점 수에 비례한다. 격자 밖 정점으로 만든 삼각형은 어차피 쓰이지 않으므로 + 격자 범위 + 여유만큼만 남기고 잘라낸다 — 결과 표고는 그대로고 속도만 는다. + """ + surface = np.full((spec.n_rows, spec.n_cols), np.nan, dtype=np.float32) + if cloud.is_empty: + return surface + margin = DRAINAGE_CONTOUR_CLIP_MARGIN_M + inside = ( + (cloud.xy[:, 0] >= spec.x_min - margin) + & (cloud.xy[:, 0] <= spec.x_min + spec.n_cols * spec.cell_m + margin) + & (cloud.xy[:, 1] >= spec.y_max - spec.n_rows * spec.cell_m - margin) + & (cloud.xy[:, 1] <= spec.y_max + margin) + ) + if inside.sum() < 3: + logger.warning("배수유역: 격자 범위 안에 등고선 정점이 없습니다.") + return surface + logger.info("배수유역: TIN 정점 %d개 사용 (전체 %d개)", int(inside.sum()), cloud.xy.shape[0]) + interpolator = LinearNDInterpolator(cloud.xy[inside], cloud.z[inside]) + xs = spec.cell_centers_x() + ys = spec.cell_centers_y() + # 행 묶음 단위로 평가해 (행×열) 좌표 배열을 한 번에 들고 있지 않게 한다. + chunk = max(1, int(4_000_000 // max(spec.n_cols, 1))) + for start in range(0, spec.n_rows, chunk): + stop = min(start + chunk, spec.n_rows) + grid_x, grid_y = np.meshgrid(xs, ys[start:stop]) + surface[start:stop] = interpolator(grid_x, grid_y).astype(np.float32) + return surface + + +# ── ⑤ 웅덩이 채움 + 평탄면 해소 ───────────────────────────────────────────── + + +def condition_surface( + surface: np.ndarray, domain: np.ndarray | None = None +) -> tuple[np.ndarray, np.ndarray]: + """가짜 웅덩이를 채우고 평탄면에 미세 경사를 준다. + + 등고선 TIN은 같은 표고 정점 3개로 이루어진 평탄 삼각형과 계단형 가짜 웅덩이를 + 필연적으로 만든다. 그대로 D8을 돌리면 흐름이 거기서 끊겨 상류 추적이 멈춘다. + + 채움은 형태학적 재구성(erosion)으로 한다. 배출구는 격자 최외곽과 유효 영역 경계(무효 + 셀에 맞닿은 유효 셀)로 둔다 — 그래야 유효 영역 전체가 하나의 평탄면으로 잠기지 않는다. + + `domain`을 주면 그 안쪽만 해석 대상으로 삼는다(1차 영역에 걸쳐 실제 생성된 셀 마스크). + """ + valid = np.isfinite(surface) + if domain is not None: + valid &= domain + if not valid.any(): + return surface, valid + ceiling = float(np.nanmax(surface)) + 1000.0 + mask = np.where(valid, surface, ceiling).astype(np.float32) + + open_boundary = np.zeros_like(valid) + open_boundary[0, :] = True + open_boundary[-1, :] = True + open_boundary[:, 0] = True + open_boundary[:, -1] = True + open_boundary |= _dilate(~valid) & valid + open_boundary &= valid + if not open_boundary.any(): + open_boundary = valid & _dilate(~valid) + + seed = np.full_like(mask, ceiling) + seed[open_boundary] = mask[open_boundary] + filled = reconstruction(seed, mask, method="erosion", footprint=np.ones((3, 3), dtype=bool)) + filled = filled.astype(np.float32) + + # 채움 뒤 더 낮은 이웃이 없는 셀 = 평탄면. 가장 가까운 비평탄 셀 쪽으로 미세 경사를 준다. + flat = valid & ~_has_lower_neighbour(filled, valid) + if flat.any(): + distance = distance_transform_edt(flat).astype(np.float32) + filled = filled + distance * np.float32(DRAINAGE_FLAT_EPSILON_M) + filled[~valid] = np.nan + return filled, valid + + +def _dilate(mask: np.ndarray) -> np.ndarray: + """8이웃 1스텝 팽창(외부는 False).""" + padded = np.zeros((mask.shape[0] + 2, mask.shape[1] + 2), dtype=bool) + padded[1:-1, 1:-1] = mask + result = np.zeros_like(mask) + for row_shift in (0, 1, 2): + for col_shift in (0, 1, 2): + result |= padded[ + row_shift : row_shift + mask.shape[0], col_shift : col_shift + mask.shape[1] + ] + return result + + +def _has_lower_neighbour(surface: np.ndarray, valid: np.ndarray) -> np.ndarray: + """8이웃 중 자기보다 낮은 셀이 하나라도 있는지. 무효 셀은 +∞로 보아 제외한다.""" + rows, cols = surface.shape + padded = np.full((rows + 2, cols + 2), np.inf, dtype=np.float32) + padded[1:-1, 1:-1] = np.where(valid, surface, np.inf) + result = np.zeros((rows, cols), dtype=bool) + center = padded[1:-1, 1:-1] + for row_shift, col_shift, _ in _NEIGHBORS: + neighbour = padded[ + 1 + row_shift : 1 + row_shift + rows, 1 + col_shift : 1 + col_shift + cols + ] + result |= neighbour < center + return result & valid + + +# ── ⑥ D8 물 방향 ──────────────────────────────────────────────────────────── + + +def compute_receivers( + spec: GridSpec, surface: np.ndarray, valid: np.ndarray +) -> tuple[np.ndarray, np.ndarray]: + """셀마다 8방향 최급강하 이웃(수신 셀)을 정한다. + + 돌려주는 `receiver`는 평탄 인덱스(row * n_cols + col)다. 더 낮은 이웃이 없는 셀(싱크)과 + 무효 셀은 자기 자신을 가리켜 흐름이 그 자리에서 멈춘다. + """ + rows, cols = spec.n_rows, spec.n_cols + padded = np.full((rows + 2, cols + 2), np.inf, dtype=np.float32) + padded[1:-1, 1:-1] = np.where(valid, surface, np.inf) + center = padded[1:-1, 1:-1] + + flat_index = np.arange(rows * cols, dtype=np.int32).reshape(rows, cols) + padded_index = np.full((rows + 2, cols + 2), -1, dtype=np.int32) + padded_index[1:-1, 1:-1] = flat_index + + best_slope = np.zeros((rows, cols), dtype=np.float32) + receiver = flat_index.copy() + step = np.zeros((rows, cols), dtype=np.float32) + + for row_shift, col_shift, factor in _NEIGHBORS: + neighbour = padded[ + 1 + row_shift : 1 + row_shift + rows, 1 + col_shift : 1 + col_shift + cols + ] + distance = np.float32(factor * spec.cell_m) + # 무효 셀끼리는 ∞−∞ = NaN이 되지만 아래 isfinite에서 걸러진다. + with np.errstate(invalid="ignore"): + slope = (center - neighbour) / distance + better = np.isfinite(slope) & (slope > best_slope) + if not better.any(): + continue + best_slope = np.where(better, slope, best_slope) + neighbour_index = padded_index[ + 1 + row_shift : 1 + row_shift + rows, 1 + col_shift : 1 + col_shift + cols + ] + receiver = np.where(better, neighbour_index, receiver) + step = np.where(better, distance, step) + return receiver.reshape(-1), step.reshape(-1) + + +# ── 오케스트레이션 ────────────────────────────────────────────────────────── + + +def build_terrain_grid( + spec: GridSpec, cloud: ContourCloud, domain: np.ndarray | None = None +) -> TerrainGrid: + """격자 범위와 등고선 구름으로 지형 해석 격자를 만든다. + + `domain`은 실제 해석할 셀 마스크(1차 영역에 걸친 셀). 주면 그 밖은 무효로 둔다. + """ + surface = interpolate_elevation(spec, cloud) + conditioned, valid = condition_surface(surface, domain) + receiver, step = compute_receivers(spec, conditioned, valid) + logger.info( + "배수유역: 격자 %d×%d (%.2fm), 해석 대상 셀 %d개", + spec.n_rows, + spec.n_cols, + spec.cell_m, + int(valid.sum()), + ) + return TerrainGrid( + spec=spec, elevation=conditioned, valid=valid, receiver=receiver, step_length=step + ) + + +# 화살표 방위 분해능. 0 = 화면상 오른쪽(+열), 시계방향으로 증가(행이 아래로 증가하므로). +AZIMUTH_STEPS = 32 +# 방위 코드 특수값. +AZIMUTH_SINK = AZIMUTH_STEPS # 32 = 제자리(더 낮은 이웃 없음) +AZIMUTH_INVALID = AZIMUTH_STEPS + 1 # 33 = 표고 없음(해석 불가) + + +def descent_azimuth( + spec: GridSpec, + surface: np.ndarray, + valid: np.ndarray, + receiver: np.ndarray, + forced: np.ndarray | None = None, +) -> np.ndarray: + """셀별 물 흐름 방위를 32방위 코드로 낸다. + + D8은 연결(도로 도달 판정)에는 충분하지만 화면에 8방위밖에 못 그린다. 실제 지표수는 + 지형 최급강하 방향으로 흐르고 그 방향은 연속값이므로, **표시는 지표면 기울기에서 뽑은 + 연속 방위를 32단계로 양자화**해 보여 준다(2026-07-31 사용자 지시). + + `forced`(세류망을 따라 흐름을 새긴 셀)는 기울기 대신 실제 수신 셀 방향을 쓴다 — 그 + 셀들은 지형 추정이 아니라 확정된 물길을 따르기 때문이다. 기울기가 0에 가까운 셀도 + 수신 셀 방향으로 대체한다. + """ + rows, cols = spec.n_rows, spec.n_cols + filled = np.where(valid, surface, np.nan) + # np.gradient는 NaN이 번지므로 무효 셀을 주변 유효값으로 임시 대체한 뒤 기울기를 잡는다. + working = np.where(np.isfinite(filled), filled, np.nanmean(filled) if valid.any() else 0.0) + grad_row, grad_col = np.gradient(working.astype(np.float64), spec.cell_m) + # 내리막 방향 = 기울기 반대. 행은 아래로 증가하므로 화면 좌표와 부호가 같다. + move_row = -grad_row + move_col = -grad_col + magnitude = np.hypot(move_row, move_col) + + index = np.arange(receiver.size, dtype=np.int64) + receiver_row = (receiver // cols - index // cols).reshape(rows, cols).astype(np.float64) + receiver_col = (receiver % cols - index % cols).reshape(rows, cols).astype(np.float64) + use_receiver = magnitude < 1e-9 + if forced is not None: + use_receiver |= forced.reshape(rows, cols) + move_row = np.where(use_receiver, receiver_row, move_row) + move_col = np.where(use_receiver, receiver_col, move_col) + + angle = np.arctan2(move_row, move_col) + code = np.rint(angle / (2.0 * math.pi / AZIMUTH_STEPS)).astype(np.int16) % AZIMUTH_STEPS + # 수신 셀이 자기 자신이거나 이동량이 없는 셀은 방향이 없다. + is_sink = (receiver.reshape(rows, cols) == index.reshape(rows, cols)) | ( + (np.abs(move_row) < 1e-12) & (np.abs(move_col) < 1e-12) + ) + code = np.where(is_sink, AZIMUTH_SINK, code) + # 세류망을 따라 흐름을 새긴 셀은 표고가 없어도 방향이 확정돼 있다. + known = valid if forced is None else (valid | forced.reshape(rows, cols)) + code = np.where(known, code, AZIMUTH_INVALID) + return code.reshape(-1).astype(np.int16) + + +def route_elevation_floor(route_z_values: list[float]) -> float | None: + """계획선 최저점에서 여유를 뺀 등고선 하한. 값이 없으면 None(필터 미적용).""" + finite = [value for value in route_z_values if math.isfinite(value) and value != 0.0] + if not finite: + return None + return min(finite) - DRAINAGE_CONTOUR_MARGIN_M diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Stream.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Stream.py new file mode 100644 index 00000000..cd376ee8 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Stream.py @@ -0,0 +1,344 @@ +"""세류망 상·하류 분리와 1차 배수유역 산정. + +도엽 하천중심선은 피처가 잘게 쪼개져 있고 지류가 본류 중간에 T자로 붙는다. 피처 단위로 +자르면 상류망이 통째로 빠지므로, 노딩 → 도로 절단 → 끝점 그래프 확산으로 **이어진 망 +전체**를 잡는다(2026-07-31 사용자 지시). + +여기서 정해진 1차 배수유역의 bbox가 곧 격자 해석 범위가 된다. +표고 해석·격자 생성은 `B04_wf1_Surface_Engine_Watershed_Grid.py`가 맡는다. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any + +import numpy as np +from scipy.interpolate import LinearNDInterpolator +from scipy.spatial import cKDTree +from shapely.geometry import LineString, MultiPolygon, Polygon, shape +from shapely.ops import substring, unary_union + +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import ( + ContourCloud, + GridSpec, + build_cell_mask, + grid_spec_from_bounds, + iter_linestrings, +) +from config.config_system import DRAINAGE_GRID_SIZE_M + +logger = logging.getLogger(__name__) + + +# ── 세류망 상·하류 분리 ───────────────────────────────────────────────────── + + +@dataclass +class StreamSplit: + """세류망을 도로 교차점에서 상·하류로 가른 결과. 검증 화면이 그대로 그린다. + + `upstream`은 **물이 흐르는 방향(상류 → 하류)으로 정렬**돼 있다. 마지막 좌표가 도로에 + 가까운 끝이다. 격자 흐름에 세류 방향을 새겨 넣을 때 이 순서를 그대로 쓴다. + """ + + upstream: list[LineString] = field(default_factory=list) # 채택 — 1차 영역의 기준 + downstream: list[LineString] = field(default_factory=list) # 도로 아래로 이어진 망 + no_contact: int = 0 # 어느 쪽에도 이어지지 않아 제외한 조각 수 + + +def split_streams_at_road( + route_line: LineString, + stream_features: list[dict[str, Any]], + cloud: ContourCloud, +) -> StreamSplit: + """세류망을 도로에서 끊고, 교차점 상류측으로 **이어진 망 전체**를 채택한다. + + 도엽 하천중심선은 피처가 잘게 쪼개져 있고 지류가 본류 중간에 T자로 붙는다. 그래서 + 피처 단위로 보면 상류망이 통째로 빠진다. 순서를 이렇게 잡는다: + + ① 세류선끼리 `unary_union`으로 노딩 — 중간에서 만나는 지류도 연결로 인식된다 + ② 도로 교차점에서 한 번 더 잘라 상·하류 조각을 물리적으로 분리한다 + ③ 끝점 그래프를 만들고, **도로 교차 노드는 통과하지 못하게** 막는다 + ④ 도로에 접한 조각을 교차점 표고와 비교해 상·하류 씨앗으로 정한다 + ⑤ 씨앗에서 퍼뜨려 이어진 망 전체를 채택 — 도로를 넘어가지 못하므로 상·하류가 섞이지 않는다 + """ + lines: list[LineString] = [] + for feature in stream_features: + geometry = feature.get("geometry") + if not geometry: + continue + try: + parsed = shape(geometry) + except Exception: # noqa: BLE001 + continue + lines.extend(line for line in iter_linestrings(parsed) if line.length > 0) + if not lines: + return StreamSplit() + + pieces, crossing_nodes = _cut_network_at_road(lines, route_line) + if not pieces: + return StreamSplit() + + node_edges: dict[tuple[float, float], list[int]] = {} + ends: list[tuple[tuple[float, float], tuple[float, float]]] = [] + for index, piece in enumerate(pieces): + head = _node_key(*piece.coords[0]) + tail = _node_key(*piece.coords[-1]) + ends.append((head, tail)) + node_edges.setdefault(head, []).append(index) + node_edges.setdefault(tail, []).append(index) + + sampler = ElevationSampler(cloud) + # 씨앗 조각 → 그 조각의 하류쪽 끝점(= 도로 교차 노드). 이 값이 물 흐름 방향의 기준이 된다. + upper_seeds: dict[int, tuple[float, float]] = {} + lower_seeds: dict[int, tuple[float, float]] = {} + for index, piece in enumerate(pieces): + touching = [node for node in ends[index] if node in crossing_nodes] + if not touching: + continue + heights = sampler.at(np.array(touching, dtype=np.float64)) + crossing_node = touching[int(np.argmin(heights))] + if _mean_elevation(piece, sampler) > float(np.min(heights)): + upper_seeds[index] = crossing_node + else: + lower_seeds[index] = crossing_node + + upstream_flow = _spread_network(upper_seeds, ends, node_edges, crossing_nodes) + downstream_flow = _spread_network(lower_seeds, ends, node_edges, crossing_nodes) + upstream = set(upstream_flow) + downstream = set(downstream_flow) - upstream + logger.info( + "배수유역: 세류 조각 %d개 → 상류망 %d개 채택 / 하류망 %d개 · 미연결 %d개 제외", + len(pieces), + len(upstream), + len(downstream), + len(pieces) - len(upstream) - len(downstream), + ) + return StreamSplit( + # 상류망은 물 흐름 방향(상류 → 하류)으로 뒤집어 둔다 — 격자 흐름 새김에 그대로 쓴다. + upstream=[ + _oriented(pieces[index], upstream_flow[index], ends[index]) + for index in sorted(upstream) + ], + downstream=[pieces[index] for index in sorted(downstream)], + no_contact=len(pieces) - len(upstream) - len(downstream), + ) + + +def _oriented( + piece: LineString, + downstream_node: tuple[float, float], + piece_ends: tuple[tuple[float, float], tuple[float, float]], +) -> LineString: + """조각을 하류쪽 끝이 마지막 좌표가 되도록 정렬한다.""" + head, _tail = piece_ends + return LineString(list(piece.coords)[::-1]) if head == downstream_node else piece + + +def _cut_network_at_road( + lines: list[LineString], route_line: LineString +) -> tuple[list[LineString], set[tuple[float, float]]]: + """세류망을 노딩한 뒤 도로 교차점에서 자르고, 그 교차 노드를 함께 돌려준다.""" + noded = unary_union(lines) + pieces: list[LineString] = [] + crossing_nodes: set[tuple[float, float]] = set() + for piece in iter_linestrings(noded): + if not piece.intersects(route_line): + pieces.append(piece) + continue + hits = _intersection_points(piece.intersection(route_line)) + positions = sorted( + { + position + for position in (piece.project(point) for point in hits) + if 0.0 < position < piece.length + } + ) + for point in hits: + crossing_nodes.add(_node_key(point.x, point.y)) + if not positions: + # 끝점이 도로에 닿은 경우 — 자를 필요는 없고 그 끝점이 곧 교차 노드다. + pieces.append(piece) + continue + bounds = [0.0, *positions, piece.length] + for start, end in zip(bounds, bounds[1:]): + if end - start <= 0: + continue + cut = _substring(piece, start, end) + if cut is not None: + pieces.append(cut) + return pieces, crossing_nodes + + +def _spread_network( + seeds: dict[int, tuple[float, float]], + ends: list[tuple[tuple[float, float], tuple[float, float]]], + node_edges: dict[tuple[float, float], list[int]], + blocked: set[tuple[float, float]], +) -> dict[int, tuple[float, float]]: + """씨앗 조각에서 끝점을 타고 퍼진다. 도로 교차 노드는 통과하지 않는다. + + 조각마다 **어느 끝점을 통해 도달했는지**를 함께 기록한다. 그 끝점이 도로에 더 가까운 + 쪽이므로 곧 그 조각의 하류 방향이다 — 세류망 전체의 물 흐름 방향이 이 한 번의 확산으로 + 같이 정해진다. + """ + downstream = dict(seeds) + queue = list(seeds) + while queue: + index = queue.pop() + for node in ends[index]: + if node in blocked: + continue + for neighbour in node_edges.get(node, ()): + if neighbour in downstream: + continue + downstream[neighbour] = node + queue.append(neighbour) + return downstream + + +def _node_key(x: float, y: float) -> tuple[float, float]: + """끝점 일치 판정용 좌표 키. 노딩 후에도 부동소수 오차가 남아 mm로 반올림한다.""" + return (round(float(x), 3), round(float(y), 3)) + + +class ElevationSampler: + """등고선 구름에서 임의 지점 표고를 읽는다 — 상·하류 판정 전용. + + 최근접 등고선 정점만 쓰면 오차가 등고선 간격(주곡선 5m)만큼 나서, 계곡 교차점 표고가 + 실제보다 한 등고선 위로 잡히고 상류 조각이 통째로 하류로 오판된다. TIN 선형보간을 + 1차로 쓰고, TIN 밖(볼록껍질 외부)만 최근접 정점으로 메운다. + """ + + def __init__(self, cloud: ContourCloud) -> None: + self._z = cloud.z + if cloud.is_empty: + self._interpolator = None + self._tree = None + return + self._interpolator = LinearNDInterpolator(cloud.xy, cloud.z) + self._tree = cKDTree(cloud.xy) + + def at(self, xy: np.ndarray) -> np.ndarray: + """(N, 2) 좌표의 표고 (N,).""" + if self._interpolator is None or self._tree is None: + return np.zeros(xy.shape[0]) + values = np.asarray(self._interpolator(xy), dtype=np.float64) + missing = ~np.isfinite(values) + if missing.any(): + _, indices = self._tree.query(xy[missing]) + values[missing] = self._z[indices] + return values + + +def _intersection_points(geometry: Any) -> list[Any]: + if geometry.is_empty: + return [] + if geometry.geom_type == "Point": + return [geometry] + if geometry.geom_type in {"MultiPoint", "GeometryCollection", "MultiLineString"}: + points: list[Any] = [] + for part in geometry.geoms: + points.extend(_intersection_points(part)) + return points + if geometry.geom_type == "LineString": + return [geometry.interpolate(0.5, normalized=True)] + return [] + + +def _substring(line: LineString, start: float, end: float) -> LineString | None: + """선형 위 [start, end] 구간을 잘라낸다.""" + piece = substring(line, start, end) + if piece.is_empty or piece.geom_type != "LineString" or piece.length <= 0: + return None + return piece + + +def _mean_elevation(line: LineString, sampler: ElevationSampler) -> float: + """선을 10m 간격으로 훑은 평균 표고.""" + samples = max(2, int(line.length // 10.0) + 1) + positions = np.linspace(0.0, line.length, samples) + points = np.array([list(line.interpolate(position).coords)[0] for position in positions]) + return float(np.mean(sampler.at(points))) + + +# ── 1차 배수유역 ──────────────────────────────────────────────────────────── + + +@dataclass +class PrimaryRegion: + """1차 배수유역과 그 안에 생성된 격자. 검증 화면이 이 내용을 그대로 그린다.""" + + split: StreamSplit + # 상류 세류망 + 노선을 반경 버퍼해 합친 영역. + area: Polygon | MultiPolygon | None + spec: GridSpec + radius_m: float + # 1차 영역에 조금이라도 걸쳐 실제로 생성된 셀 (rows, cols) bool 마스크. + cell_mask: np.ndarray | None = None + # 1차 영역 밖으로 나간 노선 길이(m). 그 구간 사면은 해석에서 빠진다는 경고 지표. + road_outside_m: float = 0.0 + + @property + def active_cells(self) -> int: + return 0 if self.cell_mask is None else int(self.cell_mask.sum()) + + +def build_primary_region( + route_line: LineString, + stream_features: list[dict[str, Any]], + cloud: ContourCloud, + radius_m: float, + cell_m: float = DRAINAGE_GRID_SIZE_M, +) -> PrimaryRegion: + """**상류 세류망 + 계획 노선**을 반경 버퍼한 범위 = 1차 배수유역, 그 안에 격자를 생성한다. + + 노선 버퍼는 세류 교차가 없는 구간의 도로도 격자 안에 들어오게 한다 — 그래야 그 구간 + 사면이 유역으로 잡힌다. 상류 세류망 선정이 정확해진 뒤 다시 포함했다(2026-07-31 사용자 지시). + + 격자는 bbox를 통째로 채우지 않는다. **도로 시작점에 셀 모서리를 맞춘 뒤, 1차 영역에 + 조금이라도 걸치는 셀만** 생성한다(2026-07-31 사용자 지시). bbox 전체를 쓰면 영역 밖 + 빈 셀이 대부분이라 의미가 없고, 원점을 bbox 좌상단에 두면 영역이 조금만 변해도 격자가 + 통째로 밀려 이전 결과와 셀이 대응되지 않는다. + + 노선이 이 영역 밖으로 나가는 길이는 따로 재서 남긴다 — 그 구간은 도로 셀이 격자에 + 없어 유역이 잡히지 않으므로 반경을 올릴지 판단하는 근거가 된다. + """ + split = split_streams_at_road(route_line, stream_features, cloud) + geometries = [route_line.buffer(radius_m)] + geometries.extend(line.buffer(radius_m) for line in split.upstream) + if not split.upstream: + logger.warning("배수유역: 상류 세류망이 없어 노선 버퍼만으로 1차 영역을 잡습니다.") + area = unary_union(geometries) + x_min, y_min, x_max, y_max = area.bounds + road_start = route_line.coords[0] + spec = grid_spec_from_bounds( + x_min, y_min, x_max, y_max, cell_m, anchor_xy=(float(road_start[0]), float(road_start[1])) + ) + cell_mask = build_cell_mask(spec, area) + outside = route_line.difference(area) + road_outside_m = float(outside.length) if not outside.is_empty else 0.0 + active = int(cell_mask.sum()) + logger.info( + "배수유역: 1차 영역 %.0f㎡ → 격자 %d×%d (%.2fm, 도로 시점 기준) 중 %d셀 생성 " + "(bbox %d셀의 %.0f%%), 노선 이탈 %.0fm/%.0fm", + area.area, + spec.n_rows, + spec.n_cols, + spec.cell_m, + active, + spec.size, + 100.0 * active / max(spec.size, 1), + road_outside_m, + route_line.length, + ) + return PrimaryRegion( + split=split, + area=area, + spec=spec, + radius_m=radius_m, + cell_mask=cell_mask, + road_outside_m=road_outside_m, + ) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router.py b/B04_wf1_Surface/B04_wf1_Surface_Router.py index c00175f4..c9cfe5a2 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router.py @@ -9,8 +9,8 @@ from uuid import UUID import aiomysql import numpy as np -from fastapi import APIRouter, Depends -from fastapi.responses import FileResponse, JSONResponse +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse, Response from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B04_wf1_Surface.B04_wf1_Surface_Engine import ( @@ -18,6 +18,10 @@ from B04_wf1_Surface.B04_wf1_Surface_Engine import ( cache_ground_points, run_surface_analysis, ) +from B04_wf1_Surface.B04_wf1_Surface_Engine_Extent import ( + planned_route_bounds, + project_epsg_from_prj, +) from B04_wf1_Surface.B04_wf1_Surface_Repository import ( clear_confirmed_surface_models, get_input_file, @@ -28,6 +32,7 @@ from B04_wf1_Surface.B04_wf1_Surface_Repository import ( from B04_wf1_Surface.B04_wf1_Surface_Schema import ( SurfaceAnalyzeRequest, SurfaceAnalyzeResponse, + SurfaceConfirmedResponse, SurfaceConfirmRequest, SurfaceConfirmResponse, SurfaceGroundStatsResponse, @@ -39,9 +44,13 @@ from B04_wf1_Surface.B04_wf1_Surface_Schema import ( ) from B04_wf1_Surface.B04_wf1_Surface_Service import confirm_surface_selection from common_util.common_util_auth import require_system_admin +from common_util.common_util_http_cache import cached_file_response from common_util.common_util_json import atomic_write_json from common_util.common_util_storage import resolve_stored_project_path -from common_util.common_util_surface_confirmation import surface_confirmation_defaults +from common_util.common_util_surface_confirmation import ( + get_surface_confirmation_params, + surface_confirmation_defaults, +) from common_util.common_util_workflow_state import ( fail_stage, start_stage, @@ -367,6 +376,85 @@ async def get_surface_point_cloud( ) +@router.get("/{project_id}/surface/confirmed", response_model=SurfaceConfirmedResponse) +async def get_confirmed_surface(project_id: UUID) -> SurfaceConfirmedResponse | JSONResponse: + """확정 지표면 구성과 지형 가장자리만 반환한다(포인트 배열 없음). + + B05 3D 배치·B11 준비화면·진입 판정이 모두 이 응답 하나를 기준으로 삼는다. + 구성이 바뀌면 signature가 달라지므로 프론트가 담아 둔 자료의 갱신 여부를 판단할 수 있다. + """ + pool = get_db_pool() + try: + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + models = await list_surface_models(connection, project_id) + params = await get_surface_confirmation_params(connection, str(project_id)) + + confirmed = next((model for model in models if model["status"] == "CONFIRMED"), None) + source_filter = params.get("source_filter") + + # 가장자리는 B05가 3D 마커 좌표를 환산할 때 쓰므로, 기존 포인트클라우드 응답과 + # 같은 파일(확정 필터의 지면 포인트)에서 읽어 값이 어긋나지 않게 한다. + processed_dir = Path(resolve_stored_project_path(stored_path)) / "B04_wf1_Surface" + processed_dir = processed_dir / "processed" + source_path = processed_dir / "structured.npz" + if source_filter: + filtered = processed_dir / f"ground_points_{source_filter}.npz" + if filtered.is_file(): + source_path = filtered + + bounds_payload: dict[str, float] | None = None + point_count: int | None = None + if source_path.is_file(): + with np.load(source_path) as stored: + bounds = np.asarray(stored["bounds"], dtype=np.float64) + if "point_count" in stored: + point_count = int(stored["point_count"]) + bounds_payload = { + "x_min": float(bounds[0, 0]), + "x_max": float(bounds[0, 1]), + "y_min": float(bounds[1, 0]), + "y_max": float(bounds[1, 1]), + "z_min": float(bounds[2, 0]), + "z_max": float(bounds[2, 1]), + } + + # 지도(2D) 초기 화면을 도로 기준으로 맞추기 위한 계획노선 범위(없으면 None). + project_root = processed_dir.parent.parent + route_bounds = planned_route_bounds(project_root, project_epsg_from_prj(project_root)) + + signature = "|".join( + str(value) + for value in ( + confirmed["id"] if confirmed else "none", + source_filter, + params.get("method"), + params.get("smooth"), + params.get("contour_interval_m"), + ) + ) + return SurfaceConfirmedResponse( + project_id=str(project_id), + model_id=int(confirmed["id"]) if confirmed else None, + source_filter=source_filter, + method=params.get("method"), + smooth=params.get("smooth"), + contour_interval_m=params.get("contour_interval_m"), + signature=signature, + point_count=point_count, + bounds=bounds_payload, + route_bounds=route_bounds, + ) + except LookupError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B04 확정 지표면 요약 조회 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "확정 지표면 정보를 불러오지 못했습니다."}, + ) + + @router.get("/{project_id}/surface/ground-stats", response_model=SurfaceGroundStatsResponse) async def get_surface_ground_stats(project_id: UUID) -> SurfaceGroundStatsResponse | JSONResponse: """manifest에서 필터별 지면 포인트 통계를 반환한다.""" @@ -512,10 +600,11 @@ async def get_wf1_analysis_status(project_id: UUID) -> dict: @router.get("/{project_id}/surface/models/{model_id}/preview", response_model=None) async def get_surface_model_preview( + request: Request, project_id: UUID, model_id: int, smooth: bool = False, -) -> FileResponse | JSONResponse: +) -> Response | JSONResponse: """지표면 모델의 3D 프리뷰 파일(GLB/PLY)을 반환한다.""" pool = get_db_pool() try: @@ -569,7 +658,8 @@ async def get_surface_model_preview( elif ext == "ply": media_type = "application/ply" - return FileResponse(preview_path, media_type=media_type, filename=preview_filename) + # 브라우저가 이미 같은 파일을 갖고 있으면 304만 돌려준다(새로고침이 빨라진다). + return cached_file_response(request, preview_path, media_type, preview_filename) except Exception: logger.exception( diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router_Contour.py b/B04_wf1_Surface/B04_wf1_Surface_Router_Contour.py index 82b11bdd..333ef4c1 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router_Contour.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router_Contour.py @@ -10,8 +10,8 @@ from pathlib import Path from uuid import UUID import numpy as np -from fastapi import APIRouter -from fastapi.responses import FileResponse, JSONResponse +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse, Response from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B04_wf1_Surface.B04_wf1_Surface_Engine_Contour import ( @@ -19,6 +19,7 @@ from B04_wf1_Surface.B04_wf1_Surface_Engine_Contour import ( extract_contours, ) from common_util.common_util_atomic import atomic_write_bytes +from common_util.common_util_http_cache import cached_file_response from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool from config.config_system import SURFACE_CONTOUR_GRID_RESOLUTION_M @@ -50,12 +51,13 @@ def _is_contour_cache_current(contour_path: Path, model_path: Path) -> bool: @router.get("/{project_id}/surface/models/{model_id}/contour", response_model=None) async def get_surface_model_contour( + request: Request, project_id: UUID, model_id: int, interval: float = 1.0, smooth: bool = False, recalculate: bool = False, -) -> FileResponse | JSONResponse: +) -> Response | JSONResponse: """지표면 모델의 등고선 JSON 파일을 반환한다.""" pool = get_db_pool() try: @@ -176,7 +178,8 @@ async def get_surface_model_contour( }, ) - return FileResponse(contour_path, media_type="application/json", filename=contour_filename) + # 브라우저가 이미 같은 등고선 파일을 갖고 있으면 304만 돌려준다(새로고침이 빨라진다). + return cached_file_response(request, contour_path, "application/json", contour_filename) except Exception: logger.exception( diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py b/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py index 48b7b73e..58b70861 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py @@ -5,10 +5,11 @@ from pathlib import Path from typing import Any from uuid import UUID -from fastapi import APIRouter, HTTPException, Response -from fastapi.responses import FileResponse, JSONResponse +from fastapi import APIRouter, HTTPException, Request, Response +from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from common_util.common_util_http_cache import cached_file_response from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool @@ -58,9 +59,9 @@ async def get_vworld_meta( # VWorld 맵 API @router.get("/{project_id}/vworld-map", response_model=None) async def get_vworld_map( - project_id: UUID, layer_name: str = "satellite" -) -> FileResponse | JSONResponse: - """배경 지도 레이어 PNG 이미지를 반환합니다.""" + project_id: UUID, request: Request, layer_name: str = "satellite" +) -> Response | JSONResponse: + """배경 지도 레이어 PNG 이미지를 반환합니다(ETag — 바뀌지 않았으면 304).""" pool = get_db_pool() try: async with pool.acquire() as connection: @@ -86,15 +87,21 @@ async def get_vworld_map( "message": f"VWorld {layer_name} 지도가 존재하지 않습니다.", }, ) - return FileResponse(map_path, media_type="image/png") + return cached_file_response(request, map_path, "image/png") except Exception as exc: return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)}) # GeoJSON 조회 API @router.get("/{project_id}/geojson", response_model=None) -async def get_project_geojson(project_id: UUID, layer: str) -> dict[str, Any] | JSONResponse: - """저장된 프로젝트의 특정 GeoJSON 레이어 데이터를 반환합니다.""" +async def get_project_geojson( + project_id: UUID, layer: str, request: Request +) -> dict[str, Any] | Response | JSONResponse: + """저장된 프로젝트의 특정 GeoJSON 레이어 데이터를 반환합니다. + + 도엽 레이어는 분석용 원본 대신 **표시용 사본**(프로젝트 주변만 잘라 좌표를 줄인 것)을 + 파일 그대로 내보낸다. 원본을 매번 읽어 재직렬화하면 등고선 한 장에 2초가 든다. + """ pool = get_db_pool() try: async with pool.acquire() as connection: @@ -140,6 +147,11 @@ async def get_project_geojson(project_id: UUID, layer: str) -> dict[str, Any] | }, ) + if layer in _SHEET_GEOJSON_FILES: + # 도엽 산출물은 자르거나 줄이지 않고 파일 그대로 보낸다(2026-08-01 사용자 지시). + # 재직렬화만 건너뛰어도 요청당 2초가 사라지고, ETag로 두 번째부터는 304가 된다. + return cached_file_response(request, filepath, "application/geo+json") + if layer == "등고선": simplified_filepath = target_dir / "등고선_bounds_simplified.geojson" if simplified_filepath.exists(): diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py b/B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py new file mode 100644 index 00000000..068418e1 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py @@ -0,0 +1,549 @@ +"""배수유역 분석 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 +import math +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 ( + drainage_dir, + 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 +from config.config_system import DRAINAGE_ARROW_SPACING_M, DRAINAGE_RESPONSE_FILENAME + +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), + } + + +def _response_path(stored_path: str) -> Path: + """분석 응답 캐시 경로. 재산정하지 않는 한 이 파일을 그대로 돌려준다.""" + return drainage_dir(stored_path) / DRAINAGE_RESPONSE_FILENAME + + +def _load_saved_response(stored_path: str) -> dict[str, Any] | None: + path = _response_path(stored_path) + if not path.exists(): + return None + try: + with path.open("r", encoding="utf-8") as file: + return json.load(file) + except (OSError, json.JSONDecodeError): + logger.warning("배수유역: 저장된 분석 응답을 읽지 못했습니다 (%s).", path) + return None + + +def _save_response(stored_path: str, payload: dict[str, Any]) -> None: + path = _response_path(stored_path) + try: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as file: + json.dump(payload, file, ensure_ascii=False) + except OSError: + logger.warning("배수유역: 분석 응답을 저장하지 못했습니다 (%s).", path) + + +@router.get("/{project_id}/drainage/primary-region", response_model=None) +async def get_primary_region( + project_id: UUID, refresh: bool = False +) -> dict[str, Any] | JSONResponse: + """배수유역 분석 결과를 돌려준다. + + 기본은 **영구저장소에 남은 결과를 그대로** 준다 — 분석이 30초 걸리므로 화면을 열 + 때마다 다시 돌릴 이유가 없다. `refresh=true`면 처음부터 다시 계산하고 덮어쓴다 + (2026-07-31 사용자 지시). + """ + pool = get_db_pool() + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + if not refresh: + saved = _load_saved_response(stored_path) + if saved is not None: + logger.info("배수유역: 저장된 분석 결과를 그대로 돌려줍니다 (%s).", stored_path) + return {**saved, "from_cache": True} + + 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], + # B05용 평균 흐름 화살표 — [lon, lat, 방위(도), 도로도달, 셀 수]. + # 세류·도로 셀을 뺀 블록 평균이라 사면 경향만 남는다. + "flow_arrows": [ + [*to_lonlat(x, y), round(math.degrees(angle), 1), reaches, cells] + for x, y, angle, reaches, cells in preview.flow_arrows + ], + # 화살표 간격(m). 화면이 화살표 크기를 정할 때 쓴다 — 서로 닿지 않게 이 값보다 짧게 그린다. + "arrow_spacing_m": DRAINAGE_ARROW_SPACING_M, + "from_cache": False, + } + # 단계 산출물을 영구저장소에 남긴다 — 기능을 붙일 때마다 여기에 단계가 하나씩 늘어난다. + 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) + # 응답 자체를 캐시로 남긴다 — 다음 조회는 배열을 재조립하지 않고 이 파일을 그대로 준다. + _save_response(prepared["stored_path"], payload) + 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 + ], + # 평균 흐름 화살표 — B05도 같은 그림을 그려야 하므로 여기 함께 남긴다. + "flow_arrow": [ + ( + Point(x, y), + { + # B05 화면은 사업지 CRS(m)로 그리므로 미터 좌표도 함께 남긴다. + "x": round(x, 2), + "y": round(y, 2), + "azimuth_deg": round(math.degrees(angle), 1), + "reaches_road": reaches, + "cells": cells, + }, + ) + for x, y, angle, reaches, cells in preview.flow_arrows + ], + }, + { + "basin_area_m2": round(preview.basin_area_m2, 1), + "pipe_count": len(preview.pipes), + "route_length_m": round(route_line.length, 1), + "arrow_count": len(preview.flow_arrows), + "arrow_spacing_m": DRAINAGE_ARROW_SPACING_M, + }, + 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 _as_polygons(geometry: Any) -> list[Any]: + if geometry is None or geometry.is_empty: + return [] + return list(geometry.geoms) if geometry.geom_type == "MultiPolygon" else [geometry] + + +def _grid_bbox_polygon(spec: Any) -> Polygon: + x_max = spec.x_min + spec.n_cols * spec.cell_m + y_min = spec.y_max - spec.n_rows * spec.cell_m + return box(spec.x_min, y_min, x_max, spec.y_max) + + +def _line_lonlat(line: Any, to_lonlat: Any) -> list[list[float]]: + return [list(to_lonlat(x, y)) for x, y in line.coords] + + +def _polygon_rings(geometry: Any, to_lonlat: Any) -> list[list[list[float]]]: + """폴리곤/멀티폴리곤의 외곽 링만 뽑아 lonlat으로 바꾼다.""" + if geometry is None or geometry.is_empty: + return [] + parts = geometry.geoms if geometry.geom_type == "MultiPolygon" else [geometry] + return [[list(to_lonlat(x, y)) for x, y in part.exterior.coords] for part in parts] + + +def _grid_bbox_lonlat(spec: Any, to_lonlat: Any) -> list[list[float]]: + x_min = spec.x_min + x_max = spec.x_min + spec.n_cols * spec.cell_m + y_max = spec.y_max + y_min = spec.y_max - spec.n_rows * spec.cell_m + corners = ((x_min, y_min), (x_min, y_max), (x_max, y_max), (x_max, y_min), (x_min, y_min)) + return [list(to_lonlat(x, y)) for x, y in corners] diff --git a/B04_wf1_Surface/B04_wf1_Surface_Schema.py b/B04_wf1_Surface/B04_wf1_Surface_Schema.py index 13605251..cf6520ad 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Schema.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Schema.py @@ -110,6 +110,27 @@ class SurfacePointCloudSampleResponse(BaseModel): rgb: list[list[int]] | None = None +class SurfaceConfirmedResponse(BaseModel): + """확정 지표면 요약 — 화면 진입 판정·준비화면·B05가 공통으로 쓰는 단일 출처. + + 포인트 배열 없이 확정값과 지형 가장자리만 담아 수 KB로 유지한다. + signature는 확정 구성이 바뀌었는지 프론트가 한 줄로 비교하기 위한 값이다. + """ + + status: str = "success" + project_id: str + model_id: int | None = None + source_filter: str | None = None + method: str | None = None + smooth: bool | None = None + contour_interval_m: float | None = None + signature: str + point_count: int | None = None + bounds: dict[str, float] | None = None + # 계획노선(B03 CSV)의 평면 범위. 지도 초기 화면을 도로 기준으로 맞출 때 쓴다. + route_bounds: dict[str, float] | None = None + + class SurfaceGroundStatsResponse(BaseModel): """필터별 지면 포인트 통계 응답.""" diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts index 7b359fd7..2b5f06b5 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts @@ -1,3 +1,5 @@ +import * as THREE from "three"; +import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import type { SurfaceBounds } from "./B04_wf1_Surface_Api_Fetch"; export const SURFACE_CAMERA_FOV = 50; @@ -43,6 +45,213 @@ export function niceScaleDistance(roughMeters: number): number { return step * base; } +/* ----------------------------------------------------------------------------- + * 커서 기준 회전·줌 (B04 포인트클라우드/지형, B05 노선 뷰어 공용) + * + * OrbitControls 기본값은 화면 중앙(target)을 축으로 돌아서, 커서로 보고 있는 봉우리가 + * 화면 밖으로 밀려난다. 여기서는 커서 아래 지형 지점을 축으로 삼아 카메라와 target을 + * 함께 돌린다 — 축이 화면에 고정되므로 시점이 튀지 않는다. + * 줌은 OrbitControls의 zoomToCursor로 같은 기준을 쓴다. + * -------------------------------------------------------------------------- */ + +/** 극점을 넘어 화면이 뒤집히지 않도록 남기는 여유각(rad). */ +const POLAR_EPSILON = 0.02; +/** 포인트클라우드 클릭 허용 반경 — 카메라 거리에 비례(멀수록 점이 성기게 보인다). */ +const POINT_PICK_RATIO = 0.01; +/** 휠 한 칸당 배율. 휠을 위로 올리면 이 값의 역수만큼 멀어진다(2026-08-01 사용자 지시). */ +const ZOOM_STEP = 0.9; +/** 회전 중심 구슬의 화면상 크기 비율(카메라 거리 대비). 멀어져도 같은 크기로 보인다. */ +const PIVOT_MARKER_RATIO = 0.012; +const PIVOT_MARKER_COLOR = 0xf59e0b; + +export interface CursorPivotOptions { + camera: THREE.PerspectiveCamera; + controls: OrbitControls; + /** 포인터 이벤트를 받는 캔버스. */ + element: HTMLElement; + /** 커서 아래에서 찾을 대상(지형 메시·포인트클라우드). 없으면 기존 축을 유지한다. */ + pickables: () => THREE.Object3D[]; + /** 마커 드래그 등 다른 조작이 잡고 있으면 회전을 넘긴다. */ + blocked?: () => boolean; + /** 회전 중심 구슬을 띄울 장면. 주지 않으면 구슬을 만들지 않는다. */ + scene?: THREE.Scene; +} + +/** 커서 기준 회전·줌을 붙이고, 해제 함수를 돌려준다. */ +export function bindCursorPivotControls(options: CursorPivotOptions): () => void { + const { camera, controls, element } = options; + // 회전·줌 모두 여기서 직접 처리한다(OrbitControls에는 휠 방향을 뒤집는 설정이 없다). + controls.enableRotate = false; + controls.enableZoom = false; + // 가운데 버튼 드래그 = 화면 이동(전역 공통). 기본값(DOLLY)은 휠 줌과 겹쳐 쓸모가 없다. + controls.mouseButtons.MIDDLE = THREE.MOUSE.PAN; + + // 회전 중심 구슬 — 돌리는 동안에만 보인다. 화면상 크기는 거리와 무관하게 일정하다. + const pivotMarker = options.scene + ? new THREE.Mesh( + new THREE.SphereGeometry(1, 16, 12), + new THREE.MeshBasicMaterial({ + color: PIVOT_MARKER_COLOR, + // 지형에 묻혀 안 보이면 축을 확인할 수 없으므로 항상 위에 그린다. + depthTest: false, + transparent: true, + opacity: 0.9, + }), + ) + : null; + if (pivotMarker && options.scene) { + pivotMarker.visible = false; + pivotMarker.renderOrder = 999; + options.scene.add(pivotMarker); + } + + /** 구슬을 현재 축 위치·크기로 맞춘다. */ + function syncPivotMarker(): void { + if (!pivotMarker || !pivotMarker.visible) return; + pivotMarker.position.copy(pivot); + const distance = camera.position.distanceTo(pivot); + pivotMarker.scale.setScalar(Math.max(distance * PIVOT_MARKER_RATIO, 0.01)); + } + + const raycaster = new THREE.Raycaster(); + const pointer = new THREE.Vector2(); + const pivot = new THREE.Vector3(); + const viewDirection = new THREE.Vector3(); + const fallbackPlane = new THREE.Plane(); + let pointerId: number | null = null; + let lastX = 0; + let lastY = 0; + + /** 커서가 가리키는 지형 위 지점을 회전축으로 잡는다. + * + * 지형을 맞히면 그 점을 쓰고, 하늘·구멍이라 못 맞히면 시선에 수직이고 현재 target을 + * 지나는 평면과 광선을 만나게 해 **커서 방향**의 점을 쓴다(화면 중앙으로 돌아가지 않는다). */ + function pickPivot(event: { clientX: number; clientY: number }): void { + pivot.copy(controls.target); + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return; + pointer.set( + ((event.clientX - rect.left) / rect.width) * 2 - 1, + -((event.clientY - rect.top) / rect.height) * 2 + 1, + ); + raycaster.setFromCamera(pointer, camera); + // 포인트클라우드는 점 사이가 비어 있어 정확히 맞히기 어렵다 — 거리에 비례한 허용 반경을 준다. + raycaster.params.Points.threshold = + camera.position.distanceTo(controls.target) * POINT_PICK_RATIO; + const hit = raycaster.intersectObjects(options.pickables(), true)[0]; + if (hit) { + pivot.copy(hit.point); + return; + } + camera.getWorldDirection(viewDirection); + fallbackPlane.setFromNormalAndCoplanarPoint(viewDirection, controls.target); + raycaster.ray.intersectPlane(fallbackPlane, pivot); + } + + function onPointerDown(event: PointerEvent): void { + // 가운데 버튼은 브라우저 기본 동작(페이지 자동 스크롤)이 화면 이동과 겹치므로 막는다. + if (event.button === 1) event.preventDefault(); + if (event.button !== 0 || pointerId !== null) return; + if (options.blocked?.() || !controls.enabled) return; + pickPivot(event); + pointerId = event.pointerId; + lastX = event.clientX; + lastY = event.clientY; + if (pivotMarker) { + pivotMarker.visible = true; + syncPivotMarker(); + } + } + + /** 휠 줌 — 커서가 가리키는 지점을 축으로 삼아 그 점이 화면에 고정된 채 멀어지고 가까워진다. + * 휠을 위로 올리면 멀어진다(사용자 지시). */ + function onWheel(event: WheelEvent): void { + if (!controls.enabled || options.blocked?.()) return; + event.preventDefault(); + pickPivot(event); + const factor = event.deltaY < 0 ? 1 / ZOOM_STEP : ZOOM_STEP; + const cameraOffset = camera.position.clone().sub(pivot).multiplyScalar(factor); + const targetOffset = controls.target.clone().sub(pivot).multiplyScalar(factor); + // 축에 너무 가까워지면 시점이 뒤집히므로 최소 거리를 남긴다. + if (cameraOffset.length() < 0.5 && factor < 1) return; + camera.position.copy(pivot).add(cameraOffset); + controls.target.copy(pivot).add(targetOffset); + camera.lookAt(controls.target); + controls.update(); + syncPivotMarker(); + } + + function onPointerMove(event: PointerEvent): void { + if (pointerId !== event.pointerId) return; + if (options.blocked?.()) { + stop(); + return; + } + const height = Math.max(element.clientHeight, 1); + // OrbitControls와 같은 감도: 화면 높이만큼 끌면 한 바퀴. + const yaw = (2 * Math.PI * (event.clientX - lastX)) / height; + const pitch = (2 * Math.PI * (event.clientY - lastY)) / height; + lastX = event.clientX; + lastY = event.clientY; + if (yaw === 0 && pitch === 0) return; + + const up = camera.up.clone().normalize(); + const cameraOffset = camera.position.clone().sub(pivot); + const targetOffset = controls.target.clone().sub(pivot); + // 수평 회전 — 화면 상하축(카메라 up) 기준. + cameraOffset.applyAxisAngle(up, -yaw); + targetOffset.applyAxisAngle(up, -yaw); + // 수직 회전 — 시선의 오른쪽 축 기준. 마우스를 내리면 위에서 내려다보는 방향(2026-08-01 + // 사용자 지시로 상하 반전). 극점을 넘으면 상하 성분만 버린다. + const eyeDirection = cameraOffset.clone().sub(targetOffset); + const right = eyeDirection.clone().cross(up); + if (right.lengthSq() > 1e-8) { + right.normalize(); + const rotated = eyeDirection.clone().applyAxisAngle(right, pitch); + const polar = rotated.angleTo(up); + if (polar > POLAR_EPSILON && polar < Math.PI - POLAR_EPSILON) { + cameraOffset.applyAxisAngle(right, pitch); + targetOffset.applyAxisAngle(right, pitch); + } + } + camera.position.copy(pivot).add(cameraOffset); + controls.target.copy(pivot).add(targetOffset); + camera.lookAt(controls.target); + controls.update(); + syncPivotMarker(); + } + + function stop(): void { + pointerId = null; + if (pivotMarker) pivotMarker.visible = false; + } + + function onPointerEnd(event: PointerEvent): void { + if (pointerId === event.pointerId) stop(); + } + + element.addEventListener("pointerdown", onPointerDown); + element.addEventListener("pointermove", onPointerMove); + element.addEventListener("pointerup", onPointerEnd); + element.addEventListener("pointercancel", onPointerEnd); + element.addEventListener("pointerleave", onPointerEnd); + element.addEventListener("wheel", onWheel, { passive: false }); + + return () => { + element.removeEventListener("pointerdown", onPointerDown); + element.removeEventListener("pointermove", onPointerMove); + element.removeEventListener("pointerup", onPointerEnd); + element.removeEventListener("pointercancel", onPointerEnd); + element.removeEventListener("pointerleave", onPointerEnd); + element.removeEventListener("wheel", onWheel); + if (pivotMarker) { + pivotMarker.removeFromParent(); + pivotMarker.geometry.dispose(); + (pivotMarker.material as THREE.Material).dispose(); + } + }; +} + export function bindSurfaceViewerTheme( applyBackground: (color: string | number) => void, ): () => void { diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows.ts new file mode 100644 index 00000000..632c197f --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows.ts @@ -0,0 +1,88 @@ +/* ============================================================================= + * 평균 흐름 화살표 렌더러 (B04·B05 공용) + * + * 백엔드가 10m 블록 평균으로 뽑아 둔 화살표를 그린다. 셀 화살표(1m)는 도면 배율에서 + * 경향이 안 보이므로 B05는 이 화살표만 쓴다. + * + * 화살표 길이는 **간격보다 짧게** 잡아 서로 닿지 않게 한다 — 격자처럼 맞물리면 + * 방향이 아니라 그물망으로 읽힌다. + * + * 좌표계는 페이지마다 다르다(B04는 lon/lat 정규화, B05는 사업지 CRS 미터). 그래서 + * 화면 변환은 호출부가 `project`로 넘긴다 — 이 파일은 좌표계를 모른다. + * ========================================================================== */ + +/** 화살표 1개 — [가로, 세로, 방위(도), 도로 도달, 셀 수]. 앞 두 값의 좌표계는 호출부가 정한다. */ +export type FlowArrow = [number, number, number, boolean, number]; + +/** 화살표 길이를 간격의 몇 배로 할지. 1보다 작아야 서로 닿지 않는다. */ +const LENGTH_RATIO = 0.55; +/** 선 두께를 길이의 몇 배로 할지. */ +const WIDTH_RATIO = 0.07; +/** 이보다 짧으면 방향이 안 읽히므로 그리지 않는다(px). */ +const MIN_LENGTH_PX = 9; +/** 화면을 가득 채우지 않도록 두는 상한(px). */ +const MAX_LENGTH_PX = 40; + +const TO_ROAD_COLOR = "rgba(153, 27, 27, 0.95)"; +const AWAY_COLOR = "rgba(30, 64, 175, 0.95)"; +const HALO_COLOR = "rgba(255, 255, 255, 0.9)"; + +/** 화살표 좌표를 캔버스 픽셀로 옮기는 함수. */ +export type ArrowProjector = (a: number, b: number) => readonly [number, number]; + +/** + * 평균 흐름 화살표를 그린다. + * + * `spacingM`은 화살표 사이 실제 간격(m), `pxPerMeter`는 현재 배율에서 1m가 몇 px인지. + * 둘을 곱해 길이를 정하므로 확대·축소에 따라 화살표도 같이 커지고 작아진다. + */ +export function drawFlowArrows( + context: CanvasRenderingContext2D, + arrows: ReadonlyArray, + spacingM: number, + pxPerMeter: number, + project: ArrowProjector, + canvas: { readonly width: number; readonly height: number }, +): void { + if (arrows.length === 0 || spacingM <= 0 || pxPerMeter <= 0) return; + const length = Math.min(spacingM * pxPerMeter * LENGTH_RATIO, MAX_LENGTH_PX); + if (length < MIN_LENGTH_PX) return; + + const reach = length / 2; + const head = length * 0.26; + const width = Math.max(0.8, length * WIDTH_RATIO); + + context.save(); + context.lineCap = "round"; + context.lineJoin = "round"; + context.setLineDash([]); + arrows.forEach(([a, b, degrees, reaches]) => { + const [x, y] = project(a, b); + if (x < -length || x > canvas.width + length) return; + if (y < -length || y > canvas.height + length) return; + const angle = (degrees * Math.PI) / 180; + const unitX = Math.cos(angle); + const unitY = Math.sin(angle); + const tailX = x - unitX * reach; + const tailY = y - unitY * reach; + const tipX = x + unitX * reach; + const tipY = y + unitY * reach; + // 어두운 배경·채움색 위에서도 읽히도록 흰 테두리를 한 겹 깔고 그 위에 색을 얹는다. + for (const [color, lineWidth] of [ + [HALO_COLOR, width + 1.4] as const, + [reaches ? TO_ROAD_COLOR : AWAY_COLOR, width] as const, + ]) { + context.strokeStyle = color; + context.lineWidth = lineWidth; + context.beginPath(); + context.moveTo(tailX, tailY); + context.lineTo(tipX, tipY); + context.moveTo(tipX, tipY); + context.lineTo(tipX - (unitX + unitY * 0.65) * head, tipY - (unitY - unitX * 0.65) * head); + context.moveTo(tipX, tipY); + context.lineTo(tipX - (unitX - unitY * 0.65) * head, tipY - (unitY + unitX * 0.65) * head); + context.stroke(); + } + }); + context.restore(); +} diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts new file mode 100644 index 00000000..05b5afc1 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts @@ -0,0 +1,628 @@ +import type { VWorldMeta } from "./B04_wf1_Surface_Api_Fetch"; + +// 2D 지도 벡터 레이어 렌더 엔진. +// GeoJSON 좌표를 로드 시 1회만 정규화 맵 좌표(0~1)로 사전 투영해 두고, +// 매 프레임에는 뷰포트·scale·offset을 합친 어파인 변환만 적용한다. +// 정규화 좌표라 뷰포트 리사이즈 시에도 재투영이 필요 없다. 원본 GeoJSON은 변형하지 않는다. + +export type GeoJsonGeometry = { + type: string; + coordinates: unknown; +}; + +export type GeoJsonFeature = { + geometry?: GeoJsonGeometry | null; + properties?: Record | null; +}; + +export type GeoJsonCollection = { + features?: GeoJsonFeature[]; +}; + +export type MarkerKind = "dot" | "x"; + +/** 상류 세류망 강조 색 — 유역 판정의 기준선이라 가장 굵고 진하게 둔다. */ +const UPSTREAM_LINE_COLOR = "rgba(29, 78, 216, 0.95)"; + +/** + * 사전 투영된 하나의 파트(선/링/점 묶음). 좌표는 정규화 맵 좌표(0~1) x,y 교차 배열. + * weights: Douglas-Peucker 가중치(정점 제거 시 발생하는 최대 오차, 종횡비 보정 좌표계). + * 렌더 시 "화면 오차 < LOD_PX가 되는 정점"만 제외해 어느 줌에서도 시각적 무손실 LOD를 얻는다. + * line 파트에만 존재하며 원본 GeoJSON은 변형하지 않는다. + */ +type PreparedPart = { + coords: Float64Array; + closed: boolean; + weights: Float64Array | null; +}; + +/** 사전 투영된 피처 1개. bbox는 정규화 좌표 기준이며 컬링에 사용한다. */ +type PreparedFeature = { + kind: "line" | "point"; + parts: PreparedPart[]; + minX: number; + minY: number; + maxX: number; + maxY: number; + /** 등고 라벨 앵커(정규화 좌표). 라벨 대상이 아니면 labelText가 null. */ + labelAnchorX: number; + labelAnchorY: number; + labelText: string | null; +}; + +export type PreparedLayer = { + features: PreparedFeature[]; +}; + +/** lon/lat → 정규화 맵 좌표 변환 계수. meta에만 의존한다. aspect는 지도 종횡비(w/h). */ +export type Normalizer = { + lonMin: number; + latMin: number; + lonRange: number; + latRange: number; + aspect: number; +}; + +/** 뷰포트 안에서 지도 이미지가 차지하는 사각형(기존 getMapRect와 동일 계산). */ +export type MapRect = { + x: number; + y: number; + width: number; + height: number; +}; + +/** 프레임 단위 뷰 상태. */ +export type ViewState = { + width: number; + height: number; + scale: number; + offsetX: number; + offsetY: number; + mapRect: MapRect; +}; + +export function createNormalizer(meta: VWorldMeta): Normalizer { + return { + lonMin: meta.lon_min, + latMin: meta.lat_min, + lonRange: meta.lon_max - meta.lon_min || 1, + latRange: meta.lat_max - meta.lat_min || 1, + aspect: meta.width_meters / Math.max(meta.height_meters, 1), + }; +} + +export function computeMapRect(meta: VWorldMeta | null, width: number, height: number): MapRect { + if (!meta) return { x: 0, y: 0, width, height }; + const mapRatio = meta.width_meters / Math.max(meta.height_meters, 1); + const viewportRatio = width / Math.max(height, 1); + const mapWidth = mapRatio > viewportRatio ? width : height * mapRatio; + const mapHeight = mapRatio > viewportRatio ? width / mapRatio : height; + return { + x: (width - mapWidth) / 2, + y: (height - mapHeight) / 2, + width: mapWidth, + height: mapHeight, + }; +} + +/** 계획도로 주변으로 보여줄 여유 거리(m). B04 하단 지도와 B05 배수유역도가 같은 값을 쓴다 + * (2026-08-01 사용자 지시: 도로 중심을 화면 중앙에, 도로 전체 + 200m까지). */ +export const ROUTE_VIEW_MARGIN_M = 200; + +/** 평면 좌표(m) 범위. */ +export interface PlanBounds { + x_min: number; + x_max: number; + y_min: number; + y_max: number; +} + +/** + * 도로 전체 + 여유 거리가 화면에 들어오도록 배율·이동량을 구한다(도로 중심이 화면 중앙). + * + * 지도 초기 화면의 유일한 정의처 — B04 하단 지도와 B05 배수유역도가 함께 쓴다. + * 배경 지도보다 넓은 범위를 요구하면 배경 크기에 맞춰 멈춘다(빈 여백을 만들지 않는다). + */ +export function computeRouteView( + meta: VWorldMeta | null, + route: PlanBounds | null, + viewportWidth: number, + viewportHeight: number, + marginM: number = ROUTE_VIEW_MARGIN_M, +): { scale: number; offsetX: number; offsetY: number } { + if (!meta || !route) return { scale: 1, offsetX: 0, offsetY: 0 }; + const mapRect = computeMapRect(meta, viewportWidth, viewportHeight); + const wantWidth = Math.max(route.x_max - route.x_min, 1) + marginM * 2; + const wantHeight = Math.max(route.y_max - route.y_min, 1) + marginM * 2; + const scale = Math.max( + Math.min(meta.width_meters / wantWidth, meta.height_meters / wantHeight), + 1, + ); + const centerX = (route.x_min + route.x_max) / 2; + const centerY = (route.y_min + route.y_max) / 2; + const baseX = mapRect.x + ((centerX - meta.x_min) / meta.width_meters) * mapRect.width; + const baseY = mapRect.y + (1 - (centerY - meta.y_min) / meta.height_meters) * mapRect.height; + return { + scale, + offsetX: -(baseX - viewportWidth / 2) * scale, + offsetY: -(baseY - viewportHeight / 2) * scale, + }; +} + +function isPoint(value: unknown): value is [number, number] { + return Array.isArray(value) && typeof value[0] === "number" && typeof value[1] === "number"; +} + +/** lon/lat 배열 → 정규화 좌표 Float64Array. 유효 정점이 없으면 null. */ +function projectRing(ring: unknown, normalizer: Normalizer): Float64Array | null { + if (!Array.isArray(ring) || ring.length === 0) return null; + const coords = new Float64Array(ring.length * 2); + let count = 0; + for (const point of ring) { + if (!isPoint(point)) continue; + coords[count * 2] = (point[0] - normalizer.lonMin) / normalizer.lonRange; + coords[count * 2 + 1] = 1 - (point[1] - normalizer.latMin) / normalizer.latRange; + count += 1; + } + if (count === 0) return null; + return count * 2 === coords.length ? coords : coords.slice(0, count * 2); +} + +function collectParts( + geometry: GeoJsonGeometry, + normalizer: Normalizer, + parts: PreparedPart[], +): "line" | "point" { + const coordinates = geometry.coordinates; + if (!Array.isArray(coordinates)) return "line"; + const push = (ring: unknown, closed: boolean): void => { + const projected = projectRing(ring, normalizer); + if (projected) parts.push({ coords: projected, closed, weights: null }); + }; + switch (geometry.type) { + case "Point": + push([coordinates], false); + return "point"; + case "MultiPoint": + push(coordinates, false); + return "point"; + case "LineString": + push(coordinates, false); + return "line"; + case "MultiLineString": + for (const line of coordinates) push(line, false); + return "line"; + case "Polygon": + for (const ring of coordinates) push(ring, true); + return "line"; + case "MultiPolygon": + for (const polygon of coordinates) { + if (!Array.isArray(polygon)) continue; + for (const ring of polygon) push(ring, true); + } + return "line"; + default: + return "line"; + } +} + +/** + * Douglas-Peucker 가중치 계산 (반복형, 스택 오버플로 방지). + * weights[i] = "허용 오차가 이 값보다 크면 정점 i를 버려도 되는" 임계값. + * 부모 구간의 오차로 상한을 걸어(cap) 어떤 허용 오차에서도 일관된 부분집합이 나오게 한다. + * y축은 1/aspect로 보정해 화면 픽셀 거리와 비례하는 좌표계에서 계산한다. + */ +function computeDpWeights(coords: Float64Array, aspect: number): Float64Array { + const n = coords.length / 2; + const weights = new Float64Array(n); + weights[0] = Infinity; + weights[n - 1] = Infinity; + if (n <= 2) return weights; + const stack: number[] = [0, n - 1]; + const caps: number[] = [Infinity]; + while (stack.length) { + const last = stack.pop()!; + const first = stack.pop()!; + const cap = caps.pop()!; + if (last - first < 2) continue; + const ax = coords[first * 2]; + const ay = coords[first * 2 + 1] / aspect; + const bx = coords[last * 2]; + const by = coords[last * 2 + 1] / aspect; + const dx = bx - ax; + const dy = by - ay; + const len = Math.sqrt(dx * dx + dy * dy); + let maxDist = -1; + let maxIndex = -1; + for (let i = first + 1; i < last; i += 1) { + const px = coords[i * 2] - ax; + const py = coords[i * 2 + 1] / aspect - ay; + const dist = len === 0 ? Math.sqrt(px * px + py * py) : Math.abs(px * dy - py * dx) / len; + if (dist > maxDist) { + maxDist = dist; + maxIndex = i; + } + } + const weight = Math.min(maxDist, cap); + weights[maxIndex] = weight; + stack.push(first, maxIndex, maxIndex, last); + caps.push(weight, weight); + } + return weights; +} + +/** 등고 라벨 앵커: LineString/MultiLineString 첫 파트의 중앙 정점 (기존 동작 유지). */ +function labelAnchorOf(geometry: GeoJsonGeometry, normalizer: Normalizer): [number, number] | null { + const coords = geometry.coordinates; + if (!Array.isArray(coords)) return null; + const line = + geometry.type === "LineString" + ? coords + : geometry.type === "MultiLineString" + ? coords[0] + : null; + if (!Array.isArray(line) || line.length === 0) return null; + const mid = line[Math.floor(line.length / 2)]; + if (!isPoint(mid)) return null; + return [ + (mid[0] - normalizer.lonMin) / normalizer.lonRange, + 1 - (mid[1] - normalizer.latMin) / normalizer.latRange, + ]; +} + +/** + * GeoJSON 컬렉션 1개를 사전 투영한다. + * labelKeys가 주어지면 계곡선(25m 배수) 피처에만 라벨 텍스트·앵커를 계산해 둔다. + */ +export function prepareLayer( + collection: GeoJsonCollection | undefined, + normalizer: Normalizer, + labelKeys?: string[], +): PreparedLayer { + const features: PreparedFeature[] = []; + for (const feature of collection?.features ?? []) { + if (!feature.geometry) continue; + const parts: PreparedPart[] = []; + const kind = collectParts(feature.geometry, normalizer, parts); + if (parts.length === 0) continue; + if (kind === "line") { + for (const part of parts) { + if (part.coords.length < 6) continue; + part.weights = computeDpWeights(part.coords, normalizer.aspect); + } + } + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const part of parts) { + const coords = part.coords; + for (let i = 0; i < coords.length; i += 2) { + const x = coords[i]; + const y = coords[i + 1]; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + } + let labelText: string | null = null; + let labelAnchorX = 0; + let labelAnchorY = 0; + if (labelKeys && labelKeys.length > 0) { + const raw = labelKeys.map((key) => feature.properties?.[key]).find((value) => value != null); + const elevation = typeof raw === "number" ? raw : Number(raw); + // 계곡선(25m 배수)만 라벨 — 전체 표기 시 화면이 숫자로 뒤덮이는 것 방지 + if (Number.isFinite(elevation) && elevation % 25 === 0) { + const anchor = labelAnchorOf(feature.geometry, normalizer); + if (anchor) { + labelText = String(elevation); + labelAnchorX = anchor[0]; + labelAnchorY = anchor[1]; + } + } + } + features.push({ kind, parts, minX, minY, maxX, maxY, labelAnchorX, labelAnchorY, labelText }); + } + return { features }; +} + +/** + * 사업지 좌표계(m) 폴리라인을 한 개 피처짜리 레이어로 사전 투영한다. + * meta의 x/y 범위와 lon/lat 범위는 같은 사각형을 가리키므로, 미터 좌표도 GeoJSON과 동일한 + * 정규화 공간으로 들어간다 — 노선 선형을 도엽 레이어 위에 그대로 겹칠 수 있다. + */ +export function prepareMetricPolyline( + points: ReadonlyArray<{ x: number; y: number }>, + meta: VWorldMeta, +): PreparedLayer { + if (points.length < 2) return { features: [] }; + const widthMeters = meta.width_meters || 1; + const heightMeters = meta.height_meters || 1; + const coords = new Float64Array(points.length * 2); + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + points.forEach((point, index) => { + const nx = (point.x - meta.x_min) / widthMeters; + const ny = 1 - (point.y - meta.y_min) / heightMeters; + coords[index * 2] = nx; + coords[index * 2 + 1] = ny; + if (nx < minX) minX = nx; + if (nx > maxX) maxX = nx; + if (ny < minY) minY = ny; + if (ny > maxY) maxY = ny; + }); + return { + features: [ + { + kind: "line", + parts: [{ coords, closed: false, weights: null }], + minX, + minY, + maxX, + maxY, + labelAnchorX: 0, + labelAnchorY: 0, + labelText: null, + }, + ], + }; +} + +/** + * 프레임당 1회 계산하는 어파인 계수. + * base = mapRect.x + norm * mapRect.width + * screen = center + (base - center) * scale + offset + * = norm * (mapRect.width * scale) + (mapRect.x * scale + center * (1 - scale) + offset) + */ +type Affine = { ax: number; bx: number; ay: number; by: number }; + +function affineOf(view: ViewState): Affine { + const centerX = view.width / 2; + const centerY = view.height / 2; + return { + ax: view.mapRect.width * view.scale, + bx: view.mapRect.x * view.scale + centerX * (1 - view.scale) + view.offsetX, + ay: view.mapRect.height * view.scale, + by: view.mapRect.y * view.scale + centerY * (1 - view.scale) + view.offsetY, + }; +} + +/** 시각적 무손실 LOD 허용 오차(화면 px). 이보다 작은 오차의 정점만 생략된다. */ +const LOD_PX = 0.75; + +function drawLineParts( + context: CanvasRenderingContext2D, + feature: PreparedFeature, + affine: Affine, +): void { + // affine.ax = 정규화 1.0당 화면 px — 종횡비 보정 좌표계의 거리를 px로 바꾸는 계수. + const tolerance = LOD_PX / affine.ax; + for (const part of feature.parts) { + const coords = part.coords; + if (coords.length < 4) continue; + const weights = part.weights; + // 현재 줌에서 화면 오차 LOD_PX 미만인 정점만 생략 (끝점은 weight=∞라 항상 유지). + // 정점 사이 보간은 하지 않는다 — 원본 데이터의 형상 그대로 표시 (2026-07-28 사용자 지시). + context.beginPath(); + let started = false; + for (let i = 0; i < coords.length; i += 2) { + if (weights && weights[i / 2] < tolerance) continue; + const x = coords[i] * affine.ax + affine.bx; + const y = coords[i + 1] * affine.ay + affine.by; + if (started) context.lineTo(x, y); + else { + context.moveTo(x, y); + started = true; + } + } + if (!started) continue; + if (part.closed) context.closePath(); + context.stroke(); + } +} + +function drawPointParts( + context: CanvasRenderingContext2D, + feature: PreparedFeature, + affine: Affine, + marker: MarkerKind, +): void { + for (const part of feature.parts) { + const coords = part.coords; + for (let i = 0; i < coords.length; i += 2) { + const x = coords[i] * affine.ax + affine.bx; + const y = coords[i + 1] * affine.ay + affine.by; + if (marker === "x") { + // 표고점: 조금 굵고 큰 X 마커 + const arm = 4; + const prevWidth = context.lineWidth; + context.lineWidth = 2; + context.beginPath(); + context.moveTo(x - arm, y - arm); + context.lineTo(x + arm, y + arm); + context.moveTo(x - arm, y + arm); + context.lineTo(x + arm, y - arm); + context.stroke(); + context.lineWidth = prevWidth; + continue; + } + context.beginPath(); + context.arc(x, y, 2, 0, Math.PI * 2); + context.fillStyle = context.strokeStyle; + context.fill(); + } + } +} + +/** 컬링 여백: 선 굵기·X 마커 팔 길이·라벨 폭을 감안한 화면 밖 판정 마진(px). */ +const CULL_MARGIN = 32; + +function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState): boolean { + const margin = CULL_MARGIN; + const minX = feature.minX * affine.ax + affine.bx; + const maxX = feature.maxX * affine.ax + affine.bx; + const minY = feature.minY * affine.ay + affine.by; + const maxY = feature.maxY * affine.ay + affine.by; + return !( + maxX < -margin || + minX > view.width + margin || + maxY < -margin || + minY > view.height + margin + ); +} + +/** 레이어 1개를 그린다. context의 lineWidth/strokeStyle은 호출부에서 설정한다. */ +export function drawPreparedLayer( + context: CanvasRenderingContext2D, + layer: PreparedLayer, + view: ViewState, + marker: MarkerKind, +): void { + const affine = affineOf(view); + for (const feature of layer.features) { + if (!isVisible(feature, affine, view)) continue; + if (feature.kind === "point") drawPointParts(context, feature, affine, marker); + else drawLineParts(context, feature, affine); + } +} + +/** 사전 계산된 계곡선 라벨을 그린다. 폰트·정렬은 호출부에서 설정한다. */ +export function drawPreparedLabels( + context: CanvasRenderingContext2D, + layer: PreparedLayer, + view: ViewState, + color: string, +): void { + const affine = affineOf(view); + const margin = CULL_MARGIN; + for (const feature of layer.features) { + if (feature.labelText === null) continue; + const x = feature.labelAnchorX * affine.ax + affine.bx; + const y = feature.labelAnchorY * affine.ay + affine.by; + if (x < -margin || x > view.width + margin) continue; + if (y < -margin || y > view.height + margin) continue; + context.lineWidth = 3; + context.strokeStyle = "rgba(255, 255, 255, 0.9)"; + context.strokeText(feature.labelText, x, y); + context.fillStyle = color; + context.fillText(feature.labelText, x, y); + } +} + +/** 채움 폴리곤 오버레이(배수유역 등). 좌표는 lon/lat 링 1개. */ +export type FilledRing = { + ring: ReadonlyArray; + /** 면적 중심에 얹을 번호. 없으면 라벨을 그리지 않는다. */ + label?: string; +}; + +/** + * lon/lat 폴리곤 링을 파스텔 채움 + 테두리 + 중심 번호로 그린다. + * 사전 투영 캐시를 쓰지 않는 소량(유역 수 개) 오버레이 전용이라 매 프레임 변환해도 부담이 없다. + */ +export function drawFilledRing( + context: CanvasRenderingContext2D, + entry: FilledRing, + normalizer: Normalizer, + view: ViewState, + color: string, +): void { + if (entry.ring.length < 3) return; + const affine = affineOf(view); + let sumX = 0; + let sumY = 0; + context.beginPath(); + entry.ring.forEach(([lon, lat], index) => { + const nx = (lon - normalizer.lonMin) / normalizer.lonRange; + const ny = 1 - (lat - normalizer.latMin) / normalizer.latRange; + const x = nx * affine.ax + affine.bx; + const y = ny * affine.ay + affine.by; + sumX += x; + sumY += y; + if (index === 0) context.moveTo(x, y); + else context.lineTo(x, y); + }); + context.closePath(); + context.fillStyle = color; + context.fill(); + context.strokeStyle = color; + context.lineWidth = 1.6; + context.stroke(); + if (!entry.label) return; + // 면적 중심(정점 평균)에 번호를 원형 배지로 얹는다. + const centerX = sumX / entry.ring.length; + const centerY = sumY / entry.ring.length; + context.beginPath(); + context.arc(centerX, centerY, 11, 0, Math.PI * 2); + context.fillStyle = color; + context.fill(); + context.strokeStyle = "rgba(255, 255, 255, 0.9)"; + context.lineWidth = 1.5; + context.stroke(); + context.font = "600 12px sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillStyle = "#1f2937"; + context.fillText(entry.label, centerX, centerY); +} + +/** 상류 세류망 강조 — B04 분석 오버레이와 B05 배수유역도가 같은 굵기·색으로 그린다. */ +export function drawUpstreamLines( + context: CanvasRenderingContext2D, + lines: ReadonlyArray>, + normalizer: Normalizer, + view: ViewState, +): void { + if (lines.length === 0) return; + const affine = affineOf(view); + context.save(); + context.setLineDash([]); + context.lineWidth = 4; + context.lineCap = "round"; + context.lineJoin = "round"; + context.strokeStyle = UPSTREAM_LINE_COLOR; + lines.forEach((line) => { + if (line.length < 2) return; + context.beginPath(); + line.forEach(([lon, lat], index) => { + const nx = (lon - normalizer.lonMin) / normalizer.lonRange; + const ny = 1 - (lat - normalizer.latMin) / normalizer.latRange; + const x = nx * affine.ax + affine.bx; + const y = ny * affine.ay + affine.by; + if (index === 0) context.moveTo(x, y); + else context.lineTo(x, y); + }); + context.stroke(); + }); + context.restore(); +} + +/** 유역 경계(분수령=능선)를 능선 스타일(갈색 파선)로 강조해 그린다. */ +export function drawRidgeRing( + context: CanvasRenderingContext2D, + ring: ReadonlyArray, + normalizer: Normalizer, + view: ViewState, +): void { + if (ring.length < 3) return; + const affine = affineOf(view); + context.beginPath(); + ring.forEach(([lon, lat], index) => { + const nx = (lon - normalizer.lonMin) / normalizer.lonRange; + const ny = 1 - (lat - normalizer.latMin) / normalizer.latRange; + const x = nx * affine.ax + affine.bx; + const y = ny * affine.ay + affine.by; + if (index === 0) context.moveTo(x, y); + else context.lineTo(x, y); + }); + context.closePath(); + context.save(); + context.strokeStyle = "#92400e"; + context.lineWidth = 1.8; + context.setLineDash([7, 4]); + context.stroke(); + context.restore(); +} diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index a5dac98e..791ee668 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -1,34 +1,38 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import { createProgressCircle } from "@ui/ui_template_progress"; +import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; import { fetchGisGeoJson, fetchVWorldMeta, getVWorldMapUrl, - type SurfaceBounds, 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, + computeRouteView, + createNormalizer, + drawPreparedLabels, + drawPreparedLayer, + prepareLayer, + type GeoJsonCollection, + type MapRect, + type Normalizer, + type PlanBounds, + type PreparedLayer, + type ViewState, +} from "./B04_wf1_Surface_UI_MapRender"; export interface SurfaceMapViewer { root: HTMLElement; - render: (projectId: string, referenceBounds?: SurfaceBounds) => void; + /** routeBounds: 계획노선 평면 범위 — 초기 화면을 도로 중심으로 맞추는 데 쓴다. */ + render: (projectId: string, routeBounds?: PlanBounds | null) => void; dispose: () => void; } -type GeoJsonGeometry = { - type: string; - coordinates: unknown; -}; - -type GeoJsonFeature = { - geometry?: GeoJsonGeometry | null; - properties?: Record | null; -}; - -type GeoJsonCollection = { - features?: GeoJsonFeature[]; -}; - const BACKGROUND_LAYERS = ["white", "satellite", "hybrid"] as const; +// 유수방향은 화면에 쓰지 않기로 해 목록에서 뺐다(2026-08-01 사용자 지시). const GIS_LAYERS = [ "지적도", "행정구역_시군구", @@ -39,11 +43,34 @@ const GIS_LAYERS = [ "도엽_표고점", "도엽_성절토", "도엽_옹벽석축", - "도엽_유수방향", ] as const; type BackgroundLayer = (typeof BACKGROUND_LAYERS)[number]; type GisLayer = (typeof GIS_LAYERS)[number]; +/* ── 처음 열었을 때 켜져 있을 레이어 (2026-08-01 사용자 지정) ────────────── + * 지도가 복잡해지지 않도록 실제로 자주 보는 것만 켠 채로 시작한다. + * 나머지는 버튼으로 그때그때 켠다. */ +const BACKGROUND_DEFAULT_ON: Record = { + white: true, + satellite: true, + hybrid: false, +}; + +const GIS_DEFAULT_ON: Record = { + 지적도: false, + 행정구역_시군구: true, + 행정구역_읍면동: true, + 등고선: false, + 도엽_등고선: false, + 도엽_하천중심선: true, + 도엽_표고점: false, + 도엽_성절토: false, + 도엽_옹벽석축: false, +}; + +/** 등고 라벨(계곡선 수치) 기본 표시 여부. */ +const CONTOUR_LABEL_DEFAULT_ON = false; + const GIS_LAYER_COLORS: Record = { 지적도: "#f97316", 행정구역_시군구: "#7c3aed", @@ -54,7 +81,6 @@ const GIS_LAYER_COLORS: Record = { 도엽_표고점: "#f9a8d4", 도엽_성절토: "#f43f5e", 도엽_옹벽석축: "#0f766e", - 도엽_유수방향: "#0891b2", }; // 등고 라벨 표기 대상 레이어와 표고 속성 키 (gpkg=CTRLN_HG, 도엽=등고수치) @@ -117,32 +143,59 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { empty.textContent = L("B04_Surface_Map_Empty"); const status = document.createElement("span"); status.className = "b04-map__status"; + // 지도 위 좌상단 문구 묶음 — 지도 상태와 배수유역 상태를 세로로 쌓는다. + // 배경지도가 복잡해 글자가 묻히므로 각 문구에 배경 칩을 깐다(2026-08-01 사용자 지시). + const statusStack = document.createElement("div"); + statusStack.className = "b04-map__status-stack"; + statusStack.append(status); const scaleBar = document.createElement("div"); scaleBar.className = "b04-map__scale"; const scaleText = document.createElement("span"); scaleBar.append(scaleText); + // 지도 정중앙 로딩 서클 — 도엽 레이어가 10종이라 다 받을 때까지 화면이 비어 보인다. + const progress = createProgressCircle({ overlay: true }); + progress.root.hidden = true; viewport.append( ...BACKGROUND_LAYERS.map((layer) => backgroundImages.get(layer)!), canvas, empty, - status, + statusStack, scaleBar, + progress.root, ); + + /** 진행률(0~1, 모르면 null)과 문구. label이 null이면 서클을 감춘다. */ + function showProgress(ratio: number | null, label: string | null): void { + progress.root.hidden = label === null; + if (label !== null) progress.set(ratio, label); + } root.append(header, viewport); let currentProjectId: string | null = null; - let referenceBounds: SurfaceBounds | null = null; let meta: VWorldMeta | null = null; - const geoJsonLayers = new Map(); - const activeBackgrounds = new Set(BACKGROUND_LAYERS); - // gpkg 등고선은 기본 꺼짐(도엽 등고선이 기본 표기), 등고 라벨은 기본 켜짐 (2026-07-26 사용자 지시) - const activeGisLayers = new Set(GIS_LAYERS.filter((layer) => layer !== "등고선")); - let showContourLabels = true; + // 초기 화면 기준이 되는 계획노선 범위(B03 CSV). 없으면 배경 전체를 보여준다. + let routeBounds: PlanBounds | null = null; + // 배수유역 오버레이가 lon/lat을 화면 좌표로 옮길 때 쓴다. 레이어 로드 시 1회 만든다. + let normalizer: Normalizer | null = null; + // 사전 투영된 렌더용 레이어. 원본 GeoJSON은 변형하지 않으며 투영 후에는 참조를 잡아두지 않는다. + const preparedLayers = new Map(); + const activeBackgrounds = new Set( + BACKGROUND_LAYERS.filter((layer) => BACKGROUND_DEFAULT_ON[layer]), + ); + const activeGisLayers = new Set(GIS_LAYERS.filter((layer) => GIS_DEFAULT_ON[layer])); + let showContourLabels = CONTOUR_LABEL_DEFAULT_ON; let scale = 1; let offsetX = 0; let offsetY = 0; let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null; let loadSequence = 0; + // rAF 스로틀: 팬/줌 이벤트는 상태만 갱신하고 프레임당 1회만 드로잉한다. + // LOD(MapRender) 덕에 프레임 렌더 비용이 낮아 매 프레임 직접 렌더가 항상 완전한 화면을 보장한다. + let frameHandle = 0; + // 캔버스 버퍼는 크기가 실제로 변할 때만 재할당한다(재할당 시 내용이 지워지므로 매 프레임 금지). + let canvasWidth = 0; + let canvasHeight = 0; + let canvasDpr = 0; function makeLayerButton( label: string, @@ -190,7 +243,6 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { 도엽_표고점: L("B04_Surface_Map_SheetElevPoint"), 도엽_성절토: L("B04_Surface_Map_SheetCutFill"), 도엽_옹벽석축: L("B04_Surface_Map_SheetWall"), - 도엽_유수방향: L("B04_Surface_Map_SheetFlowDir"), }; GIS_LAYERS.forEach((layer) => { gisButtons.append( @@ -198,20 +250,37 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { ); }); - // 등고 라벨 보기/숨기기 (기본 켜짐 — 등고선·도엽 등고선의 계곡선 수치 표기) + // 등고 라벨 보기/숨기기 (등고선·도엽 등고선의 계곡선 수치 표기) const contourLabelButton = document.createElement("button"); contourLabelButton.type = "button"; - contourLabelButton.className = "b04-map__layer-button is-active"; + contourLabelButton.className = + "b04-map__layer-button" + (CONTOUR_LABEL_DEFAULT_ON ? " is-active" : ""); contourLabelButton.textContent = L("B04_Surface_Map_ContourLabel"); - contourLabelButton.setAttribute("aria-pressed", "true"); + contourLabelButton.setAttribute("aria-pressed", String(CONTOUR_LABEL_DEFAULT_ON)); contourLabelButton.addEventListener("click", () => { showContourLabels = !showContourLabels; contourLabelButton.classList.toggle("is-active", showContourLabels); contourLabelButton.setAttribute("aria-pressed", String(showContourLabels)); - drawVectorLayer(); + scheduleDraw(); }); gisButtons.append(contourLabelButton); + // 배수유역 분석 오버레이 — 계산은 백엔드가 하고 여기서는 겹쳐 그리기만 한다. + // 상태 문구는 오버레이 전용 줄에 쓴다 — 지도 자체 상태(레이어 로딩)와 같은 칸을 쓰면 + // 나중에 끝난 쪽이 상대 문구를 지워 버린다. + const watershed = createWatershedOverlay(() => scheduleDraw()); + const watershedGroup = document.createElement("div"); + watershedGroup.className = "b04-map__control-group"; + const watershedTitle = document.createElement("span"); + watershedTitle.textContent = "배수유역"; + const watershedButtons = document.createElement("div"); + watershedButtons.className = "b04-map__layer-buttons"; + watershedButtons.append(watershed.button, ...watershed.partButtons); + watershedGroup.append(watershedTitle, watershedButtons); + controls.insertBefore(watershedGroup, resetButton); + // 안내·결과 문구는 컨트롤 줄이 아니라 지도 위에 얹는다 — 컨트롤 영역 세로 공간을 먹지 않는다. + statusStack.append(watershed.statusElement); + function updateImageTransform(): void { backgroundImages.forEach((image) => { image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; @@ -223,203 +292,39 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { image.hidden = !activeBackgrounds.has(layer); }); empty.hidden = activeBackgrounds.size > 0 || activeGisLayers.size > 0; - drawVectorLayer(); + scheduleDraw(); } - function fitReferenceBounds(): void { - if (!meta || !referenceBounds) return; + /** 계획도로가 화면 중앙에 오고 도로 전체 + 여유 200m가 보이도록 맞춘다(B05와 같은 규칙). + * + * 라이다 범위에 맞추던 것을 도로 기준으로 바꿨다 — 3D(라이다)와 2D(지도)는 다루는 범위가 + * 달라, 라이다에 맞추면 배경을 넓게 받아도 보이는 범위가 늘 같았다(2026-08-01 사용자 지시). + * 계획노선이 없으면 배경 전체를 그대로 보여준다. */ + function fitRouteView(): void { const rect = viewport.getBoundingClientRect(); - const width = Math.max(rect.width, 1); - const height = Math.max(rect.height, 1); - const mapRect = getMapRect(width, height); - const referenceWidth = Math.max(referenceBounds.x_max - referenceBounds.x_min, 1); - const referenceHeight = Math.max(referenceBounds.y_max - referenceBounds.y_min, 1); - scale = - Math.min(meta.width_meters / referenceWidth, meta.height_meters / referenceHeight) * 0.9; - const centerX = (referenceBounds.x_min + referenceBounds.x_max) / 2; - const centerY = (referenceBounds.y_min + referenceBounds.y_max) / 2; - const baseX = mapRect.x + ((centerX - meta.x_min) / meta.width_meters) * mapRect.width; - const baseY = mapRect.y + (1 - (centerY - meta.y_min) / meta.height_meters) * mapRect.height; - offsetX = -(baseX - width / 2) * scale; - offsetY = -(baseY - height / 2) * scale; + const view = computeRouteView( + meta, + routeBounds, + Math.max(rect.width, 1), + Math.max(rect.height, 1), + ); + scale = view.scale; + offsetX = view.offsetX; + offsetY = view.offsetY; } function resetView(): void { - scale = 1; - offsetX = 0; - offsetY = 0; - fitReferenceBounds(); + fitRouteView(); updateImageTransform(); - drawVectorLayer(); + scheduleDraw(); } - function getMapRect(width: number, height: number): DOMRect { - if (!meta) return new DOMRect(0, 0, width, height); - const mapRatio = meta.width_meters / Math.max(meta.height_meters, 1); - const viewportRatio = width / Math.max(height, 1); - const mapWidth = mapRatio > viewportRatio ? width : height * mapRatio; - const mapHeight = mapRatio > viewportRatio ? width / mapRatio : height; - return new DOMRect((width - mapWidth) / 2, (height - mapHeight) / 2, mapWidth, mapHeight); - } - - function toCanvasPoint( - lon: number, - lat: number, - width: number, - height: number, - ): [number, number] { - if (!meta) return [0, 0]; - const mapRect = getMapRect(width, height); - const lonRange = meta.lon_max - meta.lon_min || 1; - const latRange = meta.lat_max - meta.lat_min || 1; - const baseX = mapRect.x + ((lon - meta.lon_min) / lonRange) * mapRect.width; - const baseY = mapRect.y + mapRect.height * (1 - (lat - meta.lat_min) / latRange); - const centerX = width / 2; - const centerY = height / 2; - return [ - centerX + (baseX - centerX) * scale + offsetX, - centerY + (baseY - centerY) * scale + offsetY, - ]; - } - - function drawRing( - context: CanvasRenderingContext2D, - ring: unknown, - width: number, - height: number, - closed: boolean, - ): void { - if (!Array.isArray(ring) || ring.length === 0) return; - const points = ring.filter( - (point): point is [number, number] => - Array.isArray(point) && typeof point[0] === "number" && typeof point[1] === "number", - ); - if (points.length === 0) return; - context.beginPath(); - points.forEach(([lon, lat], index) => { - const [x, y] = toCanvasPoint(lon, lat, width, height); - if (index === 0) context.moveTo(x, y); - else context.lineTo(x, y); - }); - if (closed) context.closePath(); - context.stroke(); - } - - function drawPoint( - context: CanvasRenderingContext2D, - coordinates: unknown, - width: number, - height: number, - marker: "dot" | "x" = "dot", - ): void { - if ( - !Array.isArray(coordinates) || - typeof coordinates[0] !== "number" || - typeof coordinates[1] !== "number" - ) { - return; - } - const [x, y] = toCanvasPoint(coordinates[0], coordinates[1], width, height); - if (marker === "x") { - // 표고점: 조금 굵고 큰 X 마커 - const arm = 4; - const prevWidth = context.lineWidth; - context.lineWidth = 2; - context.beginPath(); - context.moveTo(x - arm, y - arm); - context.lineTo(x + arm, y + arm); - context.moveTo(x - arm, y + arm); - context.lineTo(x + arm, y - arm); - context.stroke(); - context.lineWidth = prevWidth; - return; - } - context.beginPath(); - context.arc(x, y, 2, 0, Math.PI * 2); - context.fillStyle = context.strokeStyle; - context.fill(); - } - - function contourLabelAnchor(geometry: GeoJsonGeometry): [number, number] | null { - const coords = geometry.coordinates; - if (!Array.isArray(coords)) return null; - const line = - geometry.type === "LineString" - ? coords - : geometry.type === "MultiLineString" - ? coords[0] - : null; - if (!Array.isArray(line) || line.length === 0) return null; - const mid = line[Math.floor(line.length / 2)]; - if (!Array.isArray(mid) || typeof mid[0] !== "number" || typeof mid[1] !== "number") { - return null; - } - return [mid[0], mid[1]]; - } - - function drawContourLabels( - context: CanvasRenderingContext2D, - width: number, - height: number, - ): void { - context.font = "600 13px sans-serif"; - context.textAlign = "center"; - context.textBaseline = "middle"; - (Object.keys(CONTOUR_LABEL_KEYS) as GisLayer[]).forEach((layer) => { - if (!activeGisLayers.has(layer)) return; - const keys = CONTOUR_LABEL_KEYS[layer] ?? []; - geoJsonLayers.get(layer)?.features?.forEach((feature) => { - if (!feature.geometry) return; - const raw = keys.map((key) => feature.properties?.[key]).find((value) => value != null); - const elevation = typeof raw === "number" ? raw : Number(raw); - // 계곡선(25m 배수)만 라벨 — 전체 표기 시 화면이 숫자로 뒤덮이는 것 방지 - if (!Number.isFinite(elevation) || elevation % 25 !== 0) return; - const anchor = contourLabelAnchor(feature.geometry); - if (!anchor) return; - const [x, y] = toCanvasPoint(anchor[0], anchor[1], width, height); - context.lineWidth = 3; - context.strokeStyle = "rgba(255, 255, 255, 0.9)"; - context.strokeText(String(elevation), x, y); - context.fillStyle = GIS_LAYER_COLORS[layer]; - context.fillText(String(elevation), x, y); - }); - }); - } - - function drawGeometry( - context: CanvasRenderingContext2D, - geometry: GeoJsonGeometry, - width: number, - height: number, - marker: "dot" | "x" = "dot", - ): void { - const coordinates = geometry.coordinates; - if (!Array.isArray(coordinates)) return; - if (geometry.type === "Point") { - drawPoint(context, coordinates, width, height, marker); - } else if (geometry.type === "MultiPoint") { - coordinates.forEach((point) => drawPoint(context, point, width, height, marker)); - } else if (geometry.type === "LineString") { - drawRing(context, coordinates, width, height, false); - } else if (geometry.type === "MultiLineString") { - coordinates.forEach((line) => drawRing(context, line, width, height, false)); - } else if (geometry.type === "Polygon") { - coordinates.forEach((ring) => drawRing(context, ring, width, height, true)); - } else if (geometry.type === "MultiPolygon") { - coordinates.forEach((polygon) => { - if (Array.isArray(polygon)) { - polygon.forEach((ring) => drawRing(context, ring, width, height, true)); - } - }); - } - } - - function drawScaleBar(width: number, height: number): void { - if (!meta || width <= 0) { + function drawScaleBar(mapRect: MapRect): void { + if (!meta || mapRect.width <= 0) { scaleBar.hidden = true; return; } - const metersPerPixel = meta.width_meters / getMapRect(width, height).width / scale; + const metersPerPixel = meta.width_meters / mapRect.width / scale; const meters = niceScaleDistance(100 * metersPerPixel); const pixels = meters / metersPerPixel; scaleBar.hidden = false; @@ -427,34 +332,65 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { scaleText.textContent = meters >= 1000 ? `${meters / 1000} km` : `${meters} m`; } + // 등고선(전국 gpkg·도엽)은 선 수가 많아 가장 아래에 얇게 깔아 다른 레이어 판독을 방해하지 않게 한다. + const isContourLayer = (layer: GisLayer): boolean => + layer === "등고선" || layer === "도엽_등고선"; + const DRAW_ORDER = [...GIS_LAYERS].sort((a, b) => + isContourLayer(a) ? -1 : isContourLayer(b) ? 1 : 0, + ); + function drawVectorLayer(): void { const rect = viewport.getBoundingClientRect(); const width = Math.max(1, Math.floor(rect.width)); const height = Math.max(1, Math.floor(rect.height)); const dpr = window.devicePixelRatio || 1; - canvas.width = Math.floor(width * dpr); - canvas.height = Math.floor(height * dpr); - canvas.style.width = `${width}px`; - canvas.style.height = `${height}px`; + // 버퍼 재할당은 캔버스 내용을 지우므로 크기가 실제로 변할 때만 수행한다. + if (width !== canvasWidth || height !== canvasHeight || dpr !== canvasDpr) { + canvasWidth = width; + canvasHeight = height; + canvasDpr = dpr; + canvas.width = Math.floor(width * dpr); + canvas.height = Math.floor(height * dpr); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + } const context = canvas.getContext("2d"); if (!context) return; context.setTransform(dpr, 0, 0, dpr, 0, 0); context.clearRect(0, 0, width, height); - // 등고선(전국 gpkg·도엽)은 선 수가 많아 가장 아래에 얇게 깔아 다른 레이어 판독을 방해하지 않게 한다. - const isContour = (layer: GisLayer): boolean => layer === "등고선" || layer === "도엽_등고선"; - const drawOrder = [...GIS_LAYERS].sort((a, b) => (isContour(a) ? -1 : isContour(b) ? 1 : 0)); - drawOrder.forEach((layer) => { + const mapRect = computeMapRect(meta, width, height); + const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect }; + DRAW_ORDER.forEach((layer) => { if (!activeGisLayers.has(layer)) return; - context.lineWidth = isContour(layer) ? 0.7 : 1.5; + const prepared = preparedLayers.get(layer); + if (!prepared) return; + context.lineWidth = isContourLayer(layer) ? 0.7 : 1.5; context.strokeStyle = GIS_LAYER_COLORS[layer]; - const marker = layer === "도엽_표고점" ? "x" : "dot"; - geoJsonLayers.get(layer)?.features?.forEach((feature) => { - if (feature.geometry) drawGeometry(context, feature.geometry, width, height, marker); - }); + drawPreparedLayer(context, prepared, view, layer === "도엽_표고점" ? "x" : "dot"); }); - if (showContourLabels) drawContourLabels(context, width, height); + if (showContourLabels) { + context.font = "600 13px sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + (Object.keys(CONTOUR_LABEL_KEYS) as GisLayer[]).forEach((layer) => { + if (!activeGisLayers.has(layer)) return; + const prepared = preparedLayers.get(layer); + if (prepared) drawPreparedLabels(context, prepared, view, GIS_LAYER_COLORS[layer]); + }); + } + // 배수유역 오버레이는 GIS 레이어 위에 얹는다 — 격자·화살표가 등고선을 덮어야 읽힌다. + if (normalizer) watershed.draw(context, normalizer, view); updateImageTransform(); - drawScaleBar(width, height); + drawScaleBar(mapRect); + } + + /** 팬/줌 등 연속 이벤트에서는 프레임당 1회만 실제 드로잉이 일어나게 한다. */ + function scheduleDraw(): void { + if (frameHandle) return; + frameHandle = window.requestAnimationFrame(() => { + frameHandle = 0; + drawVectorLayer(); + }); } async function loadLayers(): Promise { @@ -463,42 +399,59 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { const sequence = ++loadSequence; backgroundImages.forEach((image) => image.removeAttribute("src")); meta = null; - geoJsonLayers.clear(); + preparedLayers.clear(); resetView(); status.textContent = L("B04_Surface_Map_Loading"); + showProgress(0, L("B04_Surface_Map_Loading")); try { const nextMeta = await fetchVWorldMeta(projectId, "satellite"); + // 레이어가 끝나는 대로 진행률을 올린다 — 10종을 다 받을 때까지 화면이 비어 있어서다. + let done = 0; const loadedLayers = await Promise.all( GIS_LAYERS.map(async (layer) => { try { - const data = (await fetchGisGeoJson(projectId, layer)) as GeoJsonCollection; + // 도엽 레이어는 표시용 사본이라 보관함에 담아 두고 새로고침 때 그대로 쓴다. + const data = ( + layer.startsWith("도엽_") + ? await fetchCachedSheetLayer(projectId, layer) + : await fetchGisGeoJson(projectId, layer) + ) as GeoJsonCollection; return [layer, data] as const; } catch { return [layer, null] as const; + } finally { + done += 1; + if (sequence === loadSequence) { + showProgress(done / GIS_LAYERS.length, `도엽 레이어 ${done}/${GIS_LAYERS.length}`); + } } }), ); if (sequence !== loadSequence) return; meta = nextMeta; + // 좌표 변환은 여기서 1회만 수행하고, 이후 프레임은 사전 투영 결과만 사용한다. + normalizer = createNormalizer(nextMeta); + let featureCount = 0; loadedLayers.forEach(([layer, data]) => { - if (data) geoJsonLayers.set(layer, data); + if (!data) return; + featureCount += data.features?.length ?? 0; + preparedLayers.set(layer, prepareLayer(data, normalizer!, CONTOUR_LABEL_KEYS[layer])); }); BACKGROUND_LAYERS.forEach((layer) => { - backgroundImages.get(layer)!.src = `${getVWorldMapUrl(projectId, layer)}&_t=${Date.now()}`; + // 주소에 시각을 붙이면 브라우저가 매번 다시 받는다. 서버가 ETag를 주므로 그대로 쓴다. + backgroundImages.get(layer)!.src = getVWorldMapUrl(projectId, layer); }); - const featureCount = [...geoJsonLayers.values()].reduce( - (sum, collection) => sum + (collection.features?.length ?? 0), - 0, - ); status.textContent = L("B04_Surface_Map_Features").replace( "{count}", featureCount.toLocaleString(), ); resetView(); syncLayerVisibility(); + showProgress(null, null); } catch (error) { if (sequence !== loadSequence) return; status.textContent = error instanceof Error ? error.message : L("B04_Surface_Map_LoadFailed"); + showProgress(null, null); } } @@ -507,12 +460,25 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { "wheel", (event) => { event.preventDefault(); + const prevScale = scale; scale = Math.min(8, Math.max(0.5, scale * (event.deltaY < 0 ? 1.15 : 0.87))); - drawVectorLayer(); + // 마우스 커서 아래 지점이 줌 전후로 같은 화면 위치에 머물도록 offset 보정. + // screen = center + (base - center)·scale + offset 이므로, + // 커서 고정 조건을 풀면 offset' = (cursor - center)·(1 - r) + offset·r (r = scale'/scale). + const ratio = scale / prevScale; + const rect = viewport.getBoundingClientRect(); + const cursorX = event.clientX - rect.left - rect.width / 2; + const cursorY = event.clientY - rect.top - rect.height / 2; + offsetX = cursorX * (1 - ratio) + offsetX * ratio; + offsetY = cursorY * (1 - ratio) + offsetY * ratio; + scheduleDraw(); }, { passive: false }, ); viewport.addEventListener("pointerdown", (event) => { + // 중간 버튼은 브라우저 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹쳐 페이지 전체를 + // 흔들므로 기본 동작을 차단하고 지도 팬으로만 사용한다. + if (event.button === 1) event.preventDefault(); dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY }; viewport.setPointerCapture(event.pointerId); }); @@ -520,7 +486,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { if (!dragStart) return; offsetX = dragStart.offsetX + event.clientX - dragStart.x; offsetY = dragStart.offsetY + event.clientY - dragStart.y; - drawVectorLayer(); + scheduleDraw(); }); const stopDragging = (): void => { dragStart = null; @@ -528,18 +494,25 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { viewport.addEventListener("pointerup", stopDragging); viewport.addEventListener("pointercancel", stopDragging); - const resizeObserver = new ResizeObserver(drawVectorLayer); + const resizeObserver = new ResizeObserver(scheduleDraw); resizeObserver.observe(viewport); return { root, - render(projectId, nextReferenceBounds) { + // 초기 화면은 계획노선 기준이다(라이다 범위는 쓰지 않는다). + render(projectId, nextRouteBounds) { currentProjectId = projectId; - referenceBounds = nextReferenceBounds ?? null; + routeBounds = nextRouteBounds ?? null; + watershed.reset(); + watershed.setProject(projectId); void loadLayers(); }, dispose() { loadSequence += 1; + if (frameHandle) { + window.cancelAnimationFrame(frameHandle); + frameHandle = 0; + } resizeObserver.disconnect(); }, }; diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts index 4b2d5a50..2017353d 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts @@ -9,6 +9,8 @@ import { } from "@ui/ui_template_elements"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; +import { clearPreloadMark, purgeOtherProjects } from "../A00_Common/b_asset_cache"; +import { clearRouteLatestCache } from "../B05_wf2_Route/B05_wf2_Route_Api_Fetch"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { fetchWorkflowState, @@ -18,6 +20,7 @@ import { } from "../A00_Common/b_workflow_nav"; import { confirmSurfaceModel, + fetchConfirmedSurface, fetchSurfacePointCloud, fetchSurfaceStatus, listSurfaceInputFiles, @@ -85,6 +88,8 @@ function getModelFilter(model: SurfaceModelSummary): string { export async function renderB04Surface(root: HTMLElement): Promise { const guardedProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + // 새로고침으로 바로 들어온 경우에도 다른 프로젝트 자료는 보관함에서 지운다. + if (guardedProjectId) void purgeOtherProjects(guardedProjectId); if (guardedProjectId) { const user = await fetchDashboardMe(); if (user.role !== "SYSTEM_ADMIN") { @@ -300,6 +305,7 @@ export async function renderB04Surface(root: HTMLElement): Promise { const projectId = getProjectId(); if (!projectId) return; showLoadingOverlay(); + viewer.setLoading("포인트 데이터 로딩 중…"); try { pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value); terrainViewer.setReferenceBounds(pointCloud.bounds); @@ -316,23 +322,37 @@ export async function renderB04Surface(root: HTMLElement): Promise { } async function loadProjectData(projectId: string): Promise { - const [inputs, status, modelResponse] = await Promise.all([ + const [inputs, status, modelResponse, confirmed] = await Promise.all([ listSurfaceInputFiles(projectId), fetchSurfaceStatus(projectId), listSurfaceModels(projectId), + // 확정본 구성(필터·표현·평활·등고선 간격)을 그대로 시작값으로 쓴다. 여기서 바꿔도 + // DB에는 저장하지 않는다 — 관리자 확인용이라 사용자 설정을 건드리지 않는다(2026-08-01). + fetchConfirmedSurface(projectId), ]); models = modelResponse.models; + // 확정본과 같은 조합에서 시작해야 B05와 같은 파일을 보고, 보관함도 한 벌만 쓴다. + // 확정 이력이 없을 때만 개발 기본값(csf·dtm)으로 둔다. + if (confirmed.model_id) { + if (confirmed.source_filter) filterGroup.select.value = confirmed.source_filter; + if (confirmed.method) methodGroup.select.value = confirmed.method; + terrainViewer.setSmoothing(confirmed.smooth ?? false); + } + if (confirmed.contour_interval_m) + terrainViewer.setContourInterval(confirmed.contour_interval_m); renderInputFiles(inputs.files); renderStatus(status); + viewer.setLoading("포인트 데이터 로딩 중…"); try { pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value); terrainViewer.setReferenceBounds(pointCloud.bounds); viewer.render(pointCloud); - mapViewer.render(projectId, pointCloud.bounds); + // 지도(2D)는 계획노선 기준으로 연다 — 라이다 범위와 다루는 범위가 다르다. + mapViewer.render(projectId, confirmed.route_bounds); } catch { pointCloud = null; viewer.render(null); - mapViewer.render(projectId); + mapViewer.render(projectId, confirmed.route_bounds); } renderInputInfo(); updateSelectedModel(); @@ -351,6 +371,10 @@ export async function renderB04Surface(root: HTMLElement): Promise { smooth: terrainViewer.isSmoothingEnabled(), contour_interval_m: terrainViewer.getContourInterval(), }); + // 확정본이 바뀌었으므로 브라우저가 담아 둔 옛 자료를 더 이상 쓰지 않게 한다. + // 준비 표식을 지우면 아래 goToWorkflowStage가 준비 화면을 거쳐 새 자료를 담는다. + clearPreloadMark(); + clearRouteLatestCache(projectId); showToast( L("B04_Surface_Confirm_Success") .replace("{filter}", filterGroup.select.value) diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css b/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css index eb960f2b..d8036e11 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css @@ -603,25 +603,50 @@ pointer-events: none; } -.b04-map__empty, -.b04-map__status { +.b04-map__empty { position: absolute; z-index: 2; + inset: 50% auto auto 50%; + transform: translate(-50%, -50%); color: var(--color-text-secondary); font-size: var(--text-caption); } -.b04-map__empty { - inset: 50% auto auto 50%; - transform: translate(-50%, -50%); -} - -.b04-map__status { +/* 지도 위 좌상단 문구 묶음 — 지도 상태와 배수유역 상태를 세로로 쌓는다. + 지도 조작을 가리지 않도록 포인터 이벤트는 통과시킨다. */ +.b04-map__status-stack { + position: absolute; + z-index: 2; top: var(--spacing-12); left: var(--spacing-12); + display: flex; + max-width: min(62%, 900px); + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-4); + pointer-events: none; +} + +/* 배수유역 전용 상태 줄 — 지도 자체 상태(.b04-map__status)와 칸을 나눠 쓰면 + 나중에 끝난 쪽이 상대 문구를 지운다. 그래서 줄을 따로 둔다. + 배경지도(위성·지적·등고선)가 복잡해 글자가 묻히므로 배경 칩을 깐다. */ +.b04-map__status, +.b04-map__watershed-status { + display: block; padding: var(--spacing-4) var(--spacing-8); + border: 1px solid var(--color-border); border-radius: var(--radius-inputs); - background: var(--color-surface-raised); + background: color-mix(in srgb, var(--color-surface-raised) 92%, transparent); + color: var(--color-text-body); + font-size: var(--text-caption); + line-height: 1.5; + word-break: keep-all; + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} + +.b04-map__watershed-status[hidden] { + display: none; } @media (max-width: 760px) { diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts index 87a7f162..4c79e4f0 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts @@ -3,8 +3,11 @@ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js"; import { API_BASE_URL } from "@config/config_frontend"; +import { fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache"; +import { createProgressCircle } from "@ui/ui_template_progress"; import type { SurfaceBounds, SurfaceModelSummary } from "./B04_wf1_Surface_Api_Fetch"; import { + bindCursorPivotControls, bindSurfaceViewerTheme, getTopFitDistance, niceScaleDistance, @@ -23,7 +26,11 @@ export interface SurfaceTerrainViewer { onCameraChange: (listener: (state: SurfaceCameraState) => void) => void; onAxesVisibilityChange: (listener: (visible: boolean) => void) => void; isSmoothingEnabled: () => boolean; + /** 스무딩 시작값을 정한다(확정본 저장값). 다시 그리지는 않는다. */ + setSmoothing: (enabled: boolean) => void; getContourInterval: () => number; + /** 등고선 간격 시작값을 정한다(사용자가 B05에서 저장한 값). 다시 그리지는 않는다. */ + setContourInterval: (interval: number) => void; resetOptions: () => void; dispose: () => void; } @@ -167,6 +174,17 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { legendBar.append(maxValSpan, gradientDiv, minValSpan); viewerArea.append(legendBar); + // 뷰포트 정중앙 로딩 서클 — 메쉬 파일은 수십 MB라 내려받는 동안 화면이 비어 보인다. + const progress = createProgressCircle({ overlay: true }); + progress.root.hidden = true; + viewerArea.append(progress.root); + + /** 진행률(0~1, 모르면 null)과 문구를 표시한다. label이 null이면 서클을 감춘다. */ + function showProgress(ratio: number | null, label: string | null): void { + progress.root.hidden = label === null; + if (label !== null) progress.set(ratio, label); + } + // Three.js context variables let currentProjectId = ""; let currentModelsList: readonly SurfaceModelSummary[] = []; @@ -209,6 +227,16 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { let terrainMesh: THREE.Object3D | null = null; const labelElements: HTMLDivElement[] = []; + // 라벨 목록이 바뀌거나 표시 옵션을 껐다 켰을 때는 카메라가 그대로여도 다시 배치해야 한다. + let labelsDirty = true; + // 회전·줌 중심을 커서 아래 지형 지점으로 (포인트클라우드 뷰어·B05와 공용 유틸). + const releaseCursorPivot = bindCursorPivotControls({ + camera, + controls, + element: renderer.domElement, + pickables: () => (terrainMesh ? [terrainMesh] : []), + scene, + }); function disposeObject(obj: THREE.Object3D) { obj.traverse((child) => { @@ -239,6 +267,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { } labelElements.forEach((el) => el.remove()); labelElements.length = 0; + labelsDirty = true; legendBar.style.display = "none"; } @@ -303,6 +332,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { currentModelId = null; scaleBar.hidden = true; statusSpan.textContent = "모델 조회 중..."; + showProgress(null, "모델 조회 중…"); // 1. Find matching model in list // model_type is TIN / DTM / NURBS / Implicit / Meshfree (we match activeMethod) @@ -319,6 +349,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { if (!match) { statusSpan.textContent = "일치하는 완성된 모델을 찾을 수 없습니다."; + showProgress(null, null); return; } @@ -329,39 +360,39 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { const generation = ++loadGeneration; statusSpan.textContent = "3D 메쉬 파일 다운로드 중..."; + showProgress(0, "3D 메쉬 내려받는 중…"); const previewUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/preview?smooth=${isSmooth}`; try { + // 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다). + const buffer = await fetchCachedBytes(currentProjectId, previewUrl, { + onProgress: (ratio) => { + if (generation !== loadGeneration) return; + showProgress(ratio, "3D 메쉬 내려받는 중…"); + }, + }); + if (generation !== loadGeneration) return; + if (activeMethod === "meshfree") { - new PLYLoader().load( - previewUrl, - async (geometry) => { - if (generation !== loadGeneration) { - geometry.dispose(); - return; - } - geometry.computeBoundingSphere(); - const material = new THREE.PointsMaterial({ - size: 0.35, - vertexColors: geometry.hasAttribute("color"), - sizeAttenuation: true, - }); - const points = new THREE.Points(geometry, material); - points.visible = surfCheck.checked; - terrainMesh = points; - scene.add(points); - fitCamera(points); - await loadSelectedContours(modelId, isSmooth); - }, - undefined, - () => { - if (generation !== loadGeneration) return; - statusSpan.textContent = "3D 파일 로드에 실패했습니다."; - }, - ); + const geometry = new PLYLoader().parse(buffer); + geometry.computeBoundingSphere(); + const material = new THREE.PointsMaterial({ + size: 0.35, + vertexColors: geometry.hasAttribute("color"), + sizeAttenuation: true, + }); + const points = new THREE.Points(geometry, material); + points.visible = surfCheck.checked; + terrainMesh = points; + scene.add(points); + fitCamera(points); + showProgress(1, "등고선을 그리는 중…"); + await loadSelectedContours(modelId, isSmooth); + showProgress(null, null); } else { - new GLTFLoader().load( - previewUrl, + new GLTFLoader().parse( + buffer, + "", async (gltf) => { if (generation !== loadGeneration) { disposeObject(gltf.scene); @@ -377,17 +408,20 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { terrainMesh = gltf.scene; scene.add(gltf.scene); fitCamera(gltf.scene); + showProgress(1, "등고선을 그리는 중…"); await loadSelectedContours(modelId, isSmooth); + showProgress(null, null); }, - undefined, () => { if (generation !== loadGeneration) return; statusSpan.textContent = "3D 메쉬 파일이 없거나 로드할 수 없습니다."; + showProgress(null, null); }, ); } } catch (e) { - statusSpan.textContent = "에러 발생"; + statusSpan.textContent = "3D 파일 로드에 실패했습니다."; + showProgress(null, null); } } @@ -401,9 +435,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { const contourUrl = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}&recalculate=${recalculate}`; try { - const res = await fetch(contourUrl, { cache: "no-store" }); - if (!res.ok) throw new Error("등고선 조회 실패"); - const data = await res.json(); + // 등고선도 보관함에서 먼저 찾는다 — 같은 파일을 새로고침마다 다시 내려받지 않는다. + const data = await fetchCachedJson(projectId, contourUrl); if ( currentProjectId !== projectId || currentModelId !== modelId || @@ -433,6 +466,10 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { let minH = Infinity; let maxH = -Infinity; + // 등고선 한 가닥마다 3D 객체를 만들면 수백 개가 되어 그리기가 느려진다. + // 주곡선·보조곡선 두 덩어리로 합쳐 객체 2개만 만든다(2026-08-01). + const majorPoints: THREE.Vector3[] = []; + const minorPoints: THREE.Vector3[] = []; data.contours.forEach((c: any) => { if (c.level < minH) minH = c.level; @@ -441,22 +478,11 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { const points = transform(c.coordinates); if (points.length < 2) return; - const linePoints: THREE.Vector3[] = []; - for (let i = 0; i < points.length - 1; i++) { - linePoints.push(points[i], points[i + 1]); - } - - const geometry = new THREE.BufferGeometry().setFromPoints(linePoints); const isMajor = c.level % (interval * 5) === 0; - const material = new THREE.LineBasicMaterial({ - color: isMajor ? 0xd97706 : 0xf59e0b, - linewidth: isMajor ? 2 : 1, - transparent: true, - opacity: 0.8, - }); - - const segments = new THREE.LineSegments(geometry, material); - contourGroup.add(segments); + const bucket = isMajor ? majorPoints : minorPoints; + for (let i = 0; i < points.length - 1; i++) { + bucket.push(points[i], points[i + 1]); + } if (isMajor && points.length > 4) { const labelPos = points[Math.floor(points.length / 2)]; @@ -495,9 +521,25 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { viewerArea.appendChild(labelDiv); labelElements.push(labelDiv); + labelsDirty = true; } }); + // 합쳐 둔 점들을 주곡선·보조곡선 각각 한 덩어리로 올린다. + [ + { points: minorPoints, color: 0xf59e0b }, + { points: majorPoints, color: 0xd97706 }, + ].forEach(({ points, color }) => { + if (points.length === 0) return; + const geometry = new THREE.BufferGeometry().setFromPoints(points); + const material = new THREE.LineBasicMaterial({ + color, + transparent: true, + opacity: 0.8, + }); + contourGroup.add(new THREE.LineSegments(geometry, material)); + }); + if (minH !== Infinity && maxH !== -Infinity) { const nearestMin10 = Math.round(minH / 10) * 10; const nearestMax10 = Math.round(maxH / 10) * 10; @@ -538,6 +580,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { // Animation render loop let animationFrameId = 0; let hasConnected = false; + // 라벨 재계산 여부 판단용 — 직전 프레임의 카메라 자세. + const cameraMatrixSnapshot = new THREE.Matrix4(); function animate() { if (!root.isConnected) { if (!hasConnected) { @@ -547,6 +591,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { clearMesh(); clearContours(); releaseTheme(); + releaseCursorPivot(); controls.dispose(); renderer.dispose(); } @@ -569,12 +614,16 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { scaleBar.hidden = true; } - // Update labels position - labelElements.forEach((label) => { - if (typeof (label as any).__updateLabelPos === "function") { - (label as any).__updateLabelPos(); - } - }); + // 등고 라벨 위치 — 화면이 실제로 움직였을 때만 다시 계산한다(매 프레임 재계산은 낭비). + if (labelsDirty || !cameraMatrixSnapshot.equals(camera.matrixWorldInverse)) { + labelsDirty = false; + cameraMatrixSnapshot.copy(camera.matrixWorldInverse); + labelElements.forEach((label) => { + if (typeof (label as any).__updateLabelPos === "function") { + (label as any).__updateLabelPos(); + } + }); + } renderer.render(scene, camera); animationFrameId = requestAnimationFrame(animate); @@ -602,6 +651,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { labelElements.forEach((el) => { el.style.display = contourCheck.checked ? "block" : "none"; }); + labelsDirty = true; }); intervalForm.addEventListener("submit", async (e) => { @@ -655,6 +705,13 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { isSmoothingEnabled() { return !smoothCheck.disabled && smoothCheck.checked; }, + setSmoothing(enabled) { + smoothPreferred = enabled; + syncSmoothingSupport(); + }, + setContourInterval(interval) { + if (Number.isFinite(interval) && interval > 0) intervalInput.value = String(interval); + }, getContourInterval() { return Number.parseFloat(intervalInput.value); }, @@ -675,6 +732,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { cancelAnimationFrame(animationFrameId); resizeObserver.disconnect(); releaseTheme(); + releaseCursorPivot(); clearMesh(); clearContours(); controls.dispose(); diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts index a9ad9012..f87bbfec 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts @@ -1,8 +1,10 @@ import { RENDER_OPTIONS } from "@config/config_frontend"; +import { createProgressCircle } from "@ui/ui_template_progress"; import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import type { SurfaceBounds, SurfacePointCloudSampleResponse } from "./B04_wf1_Surface_Api_Fetch"; import { + bindCursorPivotControls, bindSurfaceViewerTheme, getReferenceCenter, getTopFitDistance, @@ -19,6 +21,8 @@ export interface SurfacePointCloudViewer { controlsGroup: HTMLElement; optionsGroup: HTMLElement; statusSpan: HTMLElement; + /** 로딩 서클 표시. 문구를 주면 켜고, null이면 끈다. `render()` 시 자동으로 꺼진다. */ + setLoading: (label: string | null) => void; render: (data: SurfacePointCloudSampleResponse | null) => void; setAxesVisible: (visible: boolean) => void; applyCameraState: (state: SurfaceCameraState) => void; @@ -100,6 +104,15 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { scaleBar.append(scaleText); viewerArea.append(canvas, scaleBar, statusSpan); root.append(viewerArea); + // 뷰포트 정중앙 로딩 서클 — 지도·그래프·다른 3D 뷰어와 같은 공통 컴포넌트. + const progress = createProgressCircle({ overlay: true }); + progress.root.hidden = true; + viewerArea.append(progress.root); + + function setLoading(label: string | null): void { + progress.root.hidden = label === null; + if (label !== null) progress.set(null, label); + } const renderer = new THREE.WebGLRenderer({ canvas, @@ -120,6 +133,14 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { scene.add(axes); let pointsObject: THREE.Points | null = null; + // 회전·줌 중심을 커서 아래 지점으로 (지형 뷰어·B05와 공용 유틸). + const releaseCursorPivot = bindCursorPivotControls({ + camera, + controls: orbit, + element: canvas, + pickables: () => (pointsObject ? [pointsObject] : []), + scene, + }); let currentData: SurfacePointCloudSampleResponse | null = null; let animationFrame = 0; let hasConnected = false; @@ -272,6 +293,7 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { disposed = true; cancelAnimationFrame(animationFrame); releaseTheme(); + releaseCursorPivot(); clearPoints(); orbit.dispose(); renderer.dispose(); @@ -297,7 +319,9 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { controlsGroup, optionsGroup, statusSpan, + setLoading, render(data) { + setLoading(null); currentData = data; clearPoints(); if (!data) { diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts new file mode 100644 index 00000000..57dee145 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts @@ -0,0 +1,522 @@ +import { fetchWatershedAnalysis, type WatershedAnalysis } from "./B04_wf1_Surface_Api_Fetch"; +import { drawFlowArrows } from "./B04_wf1_Surface_UI_FlowArrows"; +import { drawUpstreamLines, type Normalizer, type 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; +/** 화면상 화살표 간격 목표(px). 1m 격자를 도엽 배율로 보면 셀이 1~2px라 셀마다 그릴 수 없다. + * 이 간격이 되도록 셀을 건너뛰며 표본만 그린다 — 흐름장을 읽는 표준 방식이다. */ +const ARROW_SPACING_PX = 22; +/** 2차 전체 배수유역 외곽선 = 분수령. */ +const BASIN_RING_COLOR = "rgba(146, 64, 14, 0.95)"; +/** 기본 관 마커. */ +const PIPE_COLOR = "rgba(249, 115, 22, 0.95)"; + +/** 개별로 켜고 끌 수 있는 오버레이 갈래. */ +const PARTS = [ + { key: "primary", label: "1차 유역", color: "#059669" }, + { key: "basin", label: "2차 유역", color: "#92400e" }, + { key: "flow", label: "유역 방향", color: "#2563eb" }, + { key: "arrows", label: "평균 흐름", color: "#7c3aed" }, +] as const; +type PartKey = (typeof PARTS)[number]["key"]; + +export interface WatershedOverlay { + /** 분석 실행 + 전체 토글 버튼. 지도 헤더의 GIS 버튼 줄에 넣는다. */ + button: HTMLButtonElement; + /** 갈래별 표시 토글 버튼(1차 유역 / 2차 유역 / 유역 방향 / 평균 흐름). */ + partButtons: HTMLButtonElement[]; + /** 배수유역 전용 상태 줄. 지도 자체 상태(레이어 로딩 등)와 섞이면 서로 덮어쓴다. */ + statusElement: HTMLElement; + /** 켜져 있는지. 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; + let busy = false; + + const statusElement = document.createElement("span"); + statusElement.className = "b04-map__watershed-status"; + statusElement.hidden = true; + + /** 상태 줄을 갱신한다. 빈 문자열이면 줄 자체를 숨긴다. */ + function say(text: string): void { + statusText = text; + statusElement.textContent = text; + statusElement.hidden = text === ""; + } + + 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초 안팎이 걸리며 결과는 영구저장소에 남습니다. " + + "저장된 결과는 지도를 열 때 자동으로 표시되므로, 조건을 바꿨을 때만 누르면 됩니다."; + + // 갈래별 표시 여부. 전체 토글(button)이 꺼져 있으면 이 값과 무관하게 아무것도 안 그린다. + // 1차 유역·유역 방향은 격자가 지도를 덮어 판독을 방해하므로 기본 꺼짐(2026-08-01 사용자 지시). + const shownParts: Record = { + primary: false, + basin: true, + flow: false, + arrows: true, + }; + const partButtons = PARTS.map((part) => { + const element = document.createElement("button"); + element.type = "button"; + const initialActive = shownParts[part.key]; + element.className = + "b04-map__layer-button b04-map__layer-button--gis" + (initialActive ? " is-active" : ""); + element.textContent = part.label; + element.style.setProperty("--b04-layer-color", part.color); + element.setAttribute("aria-pressed", String(initialActive)); + element.addEventListener("click", () => { + shownParts[part.key] = !shownParts[part.key]; + element.classList.toggle("is-active", shownParts[part.key]); + element.setAttribute("aria-pressed", String(shownParts[part.key])); + onChange(); + }); + return element; + }); + + let projectId: string | null = null; + + function strokeLonLat( + context: CanvasRenderingContext2D, + line: ReadonlyArray, + map: Normalizer, + view: ViewState, + ): void { + if (line.length < 2) return; + const ax = view.mapRect.width * view.scale; + const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; + const ay = view.mapRect.height * view.scale; + const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; + context.beginPath(); + line.forEach(([lon, lat], index) => { + const x = ((lon - map.lonMin) / map.lonRange) * ax + bx; + const y = (1 - (lat - map.latMin) / map.latRange) * ay + by; + if (index === 0) context.moveTo(x, y); + else context.lineTo(x, y); + }); + context.stroke(); + } + + /** 흐름 방향 바이트를 셀 순서대로 디코드한다(캐시 — 매 프레임 다시 풀지 않는다). */ + function flowBytes(region: WatershedAnalysis): Uint8Array | null { + if (!region.flow) return null; + if (flowCache?.source === region.flow.data) return flowCache.bytes; + const binary = atob(region.flow.data); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + flowCache = { source: region.flow.data, bytes }; + return bytes; + } + + /** 1차 영역에 걸쳐 실제로 생성된 셀만 그린다. + * + * bbox 전체를 채우지 않는다 — 백엔드가 준 행별 구간(row_spans)만 그린다. 흐름 판정이 + * 있으면 셀마다 방향 화살표를 얹고, 도로에 물이 닿는 셀은 적색·닿지 않으면 파랑으로 + * 칠한다. 셀이 화면에서 작아지면 화살표가 안 보이므로 채움색만 남긴다. */ + function drawGridCells( + context: CanvasRenderingContext2D, + map: Normalizer, + view: ViewState, + region: WatershedAnalysis, + ): void { + const ring = region.grid.bbox_lonlat; + if (ring.length < 4) return; + const lons = ring.map(([lon]) => lon); + const lats = ring.map(([, lat]) => lat); + const ax = view.mapRect.width * view.scale; + const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; + const ay = view.mapRect.height * view.scale; + const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; + const left = ((Math.min(...lons) - map.lonMin) / map.lonRange) * ax + bx; + const right = ((Math.max(...lons) - map.lonMin) / map.lonRange) * ax + bx; + const top = (1 - (Math.max(...lats) - map.latMin) / map.latRange) * ay + by; + const bottom = (1 - (Math.min(...lats) - map.latMin) / map.latRange) * ay + by; + + const { rows, cols, row_spans: spans } = region.grid; + const cellW = (right - left) / Math.max(cols, 1); + const cellH = (bottom - top) / Math.max(rows, 1); + const cellPx = Math.min(Math.abs(cellW), Math.abs(cellH)); + const bytes = flowBytes(region); + // 1m 격자를 도엽 전체 배율로 보면 셀이 1~2px라 셀마다 화살표를 그리면 아무것도 안 보인다. + // 화면에서 대략 ARROW_SPACING_PX 간격이 되도록 셀을 건너뛰며 표본만 그린다. + const stride = Math.max(1, Math.ceil(ARROW_SPACING_PX / Math.max(cellPx, 0.01))); + const arrowPx = cellPx * stride; + + 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) { + const col = colStart + offset; + // 화살표는 표본만 그린다 — 격자가 촘촘하면 셀마다 그려 봐야 뭉개져서 안 보인다. + const sampled = row % stride === 0 && col % stride === 0; + drawFlowCell( + context, + bytes[base + offset], + left + cellW * col, + y, + cellW, + cellH, + cellPx, + sampled ? arrowPx : 0, + { sink, invalid, steps }, + ); + } + }); + context.restore(); + } + + /** 셀 하나 — 도달 여부로 칠하고, 충분히 크면 32방위 흐름 화살표를 얹는다. */ + function drawFlowCell( + context: CanvasRenderingContext2D, + code: number, + x: number, + y: number, + cellW: number, + cellH: number, + cellPx: number, + arrowPx: 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); + } + // arrowPx = 0 이면 표본에서 빠진 셀이라 채움만 하고 끝낸다. + if (arrowPx < 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, arrowPx * 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 = arrowPx * 0.38; + const tipX = midX + unitX * reach; + const tipY = midY + unitY * reach; + // 표본 화살표는 채움색 위에서도 읽혀야 하므로 흰 테두리를 한 겹 깔고 그 위에 그린다. + const width = Math.max(1, arrowPx * 0.08); + const head = arrowPx * 0.18; + const stem: [number, number][] = [ + [midX - unitX * reach, midY - unitY * reach], + [tipX, tipY], + ]; + for (const [color, lineWidth] of [ + ["rgba(255, 255, 255, 0.85)", width + 1.6] as const, + [stroke, width] as const, + ]) { + context.strokeStyle = color; + context.lineWidth = lineWidth; + context.beginPath(); + context.moveTo(stem[0][0], stem[0][1]); + context.lineTo(stem[1][0], stem[1][1]); + // 촉 — 진행 방향 기준 좌우로 짧게 접는다. + 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(); + // 1차 배수유역 = 상류 세류망의 반경 버퍼 합집합. (격자는 draw()에서 먼저 깔았다) + 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)); + context.restore(); + // ④ 채택된 상류망 = 1차 영역의 기준선. 가장 굵게, 맨 위에. + // 그리기는 B05 배수유역도와 같은 공용 렌더러에 맡긴다. + drawUpstreamLines(context, region.upstream_lines, map, view); + } + + /** B05에도 같이 쓰는 평균 흐름 화살표. 그리기는 공용 렌더러에 맡긴다. */ + function drawMeanArrows( + context: CanvasRenderingContext2D, + map: Normalizer, + view: ViewState, + region: WatershedAnalysis, + ): void { + // 격자 bbox의 경도 폭과 실폭(m)으로 1m당 픽셀을 환산한다. + const lons = region.grid.bbox_lonlat.map(([lon]) => lon); + const spanLon = Math.max(...lons) - Math.min(...lons); + if (!(spanLon > 0) || !(region.grid.width_m > 0)) return; + const ax = view.mapRect.width * view.scale; + const pxPerMeter = ((spanLon / map.lonRange) * ax) / region.grid.width_m; + 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; + drawFlowArrows( + context, + region.flow_arrows ?? [], + region.arrow_spacing_m ?? 0, + pxPerMeter, + (lon, lat) => [ + ((lon - map.lonMin) / map.lonRange) * ax + bx, + (1 - (lat - map.latMin) / map.latRange) * ay + by, + ], + view, + ); + } + + /** ⑦ 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)}㎡`; + } + + /** 분석 결과를 받아 화면에 올린다. + + * `refresh=false`면 영구저장소에 남은 결과를 그대로 받아 즉시 끝나므로 지도를 열 때 + * 자동으로 부른다. `refresh=true`(재산정 버튼)면 처음부터 다시 계산한다. */ + async function loadAnalysis(refresh: boolean): Promise { + if (!projectId || busy) return; + busy = true; + button.disabled = true; + button.textContent = refresh ? "분석 중…" : "불러오는 중…"; + say( + refresh + ? "배수유역을 처음부터 다시 분석하는 중입니다. 30초 안팎 걸립니다…" + : "저장된 배수유역 분석을 불러오는 중…", + ); + const started = performance.now(); + try { + analysis = await fetchWatershedAnalysis(projectId, refresh); + shown = true; + button.classList.add("is-active"); + button.setAttribute("aria-pressed", "true"); + const seconds = ((performance.now() - started) / 1000).toFixed(1); + const origin = analysis.from_cache ? "저장분" : `재산정 ${seconds}초`; + say(`[${origin}] ${regionSummary(analysis)}`); + } catch (error) { + analysis = null; + shown = false; + button.classList.remove("is-active"); + button.setAttribute("aria-pressed", "false"); + const message = error instanceof Error ? error.message : "배수유역을 불러오지 못했습니다."; + // 저장분이 아직 없는 것은 오류가 아니다 — 무엇을 눌러야 하는지 알려 준다. + say( + refresh + ? `유역 분석 실패: ${message}` + : "저장된 배수유역 분석이 없습니다. [유역 분석]을 누르세요.", + ); + } finally { + busy = false; + button.disabled = false; + button.textContent = "유역 분석"; + onChange(); + } + } + + // 재산정 버튼은 언제나 처음부터 다시 분석한다. + // (저장분을 자동으로 띄우게 바꾼 뒤로 이 버튼이 표시 토글로 먼저 걸려, 눌러도 아무 일이 + // 없는 것처럼 보였다 — 2026-08-01. 보이기/숨기기는 갈래별 버튼이 맡는다.) + button.addEventListener("click", () => { + void loadAnalysis(true); + }); + + return { + button, + partButtons, + statusElement, + visible: () => shown && analysis !== null, + status: () => statusText, + reset() { + analysis = null; + flowCache = null; + shown = false; + say(""); + button.classList.remove("is-active"); + button.setAttribute("aria-pressed", "false"); + }, + setProject(next: string) { + projectId = next; + // 저장분이 있으면 즉시 올린다 — 없으면 조용히 넘어가고, 재산정 버튼을 누르면 계산한다. + void loadAnalysis(false); + }, + draw(context, map, view) { + if (!shown || !analysis) return; + // 격자·화살표(유역 방향) → 1차 영역 → 2차 유역·관 순으로 아래에서 위로 쌓는다. + if (shownParts.flow) drawGridCells(context, map, view, analysis); + if (shownParts.primary) drawPrimaryRegion(context, map, view, analysis); + if (shownParts.arrows) drawMeanArrows(context, map, view, analysis); + if (shownParts.basin) drawBasinAndPipes(context, map, view, analysis); + }, + }; +} diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index 60637049..b6ef26e1 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -11,7 +11,7 @@ * - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환. * ========================================================================== */ -import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; +import { API_ANALYSIS_TIMEOUT_MS, API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; /** 경로 제어점 (BP/EP/CP) */ export interface RoutePoint { @@ -141,10 +141,17 @@ export interface RouteLatestResponse { } | null; } -/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */ -async function requestJson(path: string, init: RequestInit): Promise { +/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. + * + * `timeoutMs`를 주면 그 값으로 끊는다. 격자 해석처럼 오래 걸리는 요청은 + * `API_ANALYSIS_TIMEOUT_MS`를 넘긴다 — 기본값으로 두면 계산 도중 abort 된다. */ +async function requestJson( + path: string, + init: RequestInit, + timeoutMs: number = API_TIMEOUT_MS, +): Promise { const controller = new AbortController(); - const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); + const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(`${API_BASE_URL}${path}`, { ...init, @@ -160,6 +167,12 @@ async function requestJson(path: string, init: RequestInit): Promise { throw new Error(payload.message ?? `HTTP ${response.status}`); } return payload; + } catch (error) { + // AbortError 원문("signal is aborted without reason")은 원인을 알 수 없으니 바꿔 준다. + if (error instanceof DOMException && error.name === "AbortError") { + throw new Error(`요청이 ${Math.round(timeoutMs / 1000)}초 안에 끝나지 않았습니다.`); + } + throw error; } finally { window.clearTimeout(timeoutId); } @@ -247,3 +260,100 @@ export async function fetchLatestRoute(projectId: string): Promise `b05:latest:${projectId}`; + +/** 담아 둔 최신 경로 값을 버린다. B04에서 지표면을 다시 확정하면 옛 확정값이 남아 + * B05가 이전 지형을 그리게 되므로, 확정 직후 이 값을 지운다. */ +export function clearRouteLatestCache(projectId: string): void { + try { + window.sessionStorage.removeItem(routeLatestCacheKey(projectId)); + } catch { + /* 세션 접근 실패 시에는 다음 진입에서 DB를 읽게 되므로 그대로 둔다. */ + } +} + +/* ── 배수유역도 (B05_wf2_Route_Router_Drainage.py) ───────────────────────── */ + +/** 관 매설 구조물 측점 후보 1개. reason: stream=세류 교차, spacing=300m 보충. */ +export interface DrainageCandidate { + chainage_m: number; + x: number; + y: number; + lon: number; + lat: number; + reason: "stream" | "spacing" | "confirmed"; + stream_name: string | null; +} + +export interface DrainageCandidateResponse { + status: string; + project_id: string; + route_id: number; + candidates: DrainageCandidate[]; +} + +/** 관 1개가 받는 세부 배수유역. 관경(pipe_diameter_mm)은 수식 미확정이라 당분간 항상 null이다. */ +export interface DrainageBasin { + index: number; + chainage_m: number; + /** 관(배관) 매설 지점 좌표 — 계획선 위 마커 렌더용. */ + outlet_lonlat: [number, number]; + polygon_lonlat: Array<[number, number]>; + area_m2: number; + relief_m: number; + flow_length_m: number; + pipe_diameter_mm: number | null; +} + +export interface DrainageBasinResponse { + status: string; + project_id: string; + route_id: number; + /** 산정에 실제 사용된 배관 지점 — 유역이 없는 관도 포함(마커 동기화용). */ + pipes: DrainageCandidate[]; + /** B04가 분석에 쓴 계획 노선 선형(lon/lat). */ + route_lonlat: Array<[number, number]>; + /** 2차 전체 배수유역 외곽선 = 분수령. 편집 핸들 간격으로 다시 찍고 저장된 편집분이 반영된 값. */ + main_polygon_lonlat: Array<[number, number]>; + /** 외곽선 편집 핸들 간격(m). */ + boundary_spacing_m: number; + /** 저장돼 있던 외곽선 편집 포인트(원래 자리 base, 옮긴 자리 moved). */ + boundary_overrides: Array<{ base: [number, number]; moved: [number, number] }>; + /** 새 유역 안쪽으로 들어가 버려진 편집 포인트 수. */ + boundary_dropped: number; + /** 유역 안쪽 상류 세류망 — 하이라이트 토글용. */ + upstream_lonlat: Array>; + /** B04 해석 격자 한 변(m). */ + grid_cell_m: number; + /** 평균 흐름 화살표 — [x, y(사업지 CRS m), 방위(도), 도로도달, 셀 수]. */ + flow_arrows: Array<[number, number, number, boolean, number]>; + /** 화살표 사이 실제 간격(m). */ + arrow_spacing_m: number; + basins: DrainageBasin[]; +} + +/** chainages를 주면 그 위치로 확정 산정하고, 비우면 자동 제안분으로 산정한다. */ +export async function fetchDrainageBasins( + projectId: string, + chainages?: number[], +): Promise { + // 격자 해석이 포함된 요청이라 캐시가 없으면 수십 초가 걸린다. + return requestJson( + `/projects/${projectId}/drainage/basins`, + { method: "POST", body: JSON.stringify({ chainages: chainages ?? [] }) }, + API_ANALYSIS_TIMEOUT_MS, + ); +} + +/** 사용자가 옮긴 유역 외곽선 포인트만 저장한다(종단 경로 확정 시 모달 승인 후 호출). */ +export async function saveDrainageBoundary( + projectId: string, + points: Array<{ base: [number, number]; moved: [number, number] }>, +): Promise<{ status: string; saved: number }> { + return requestJson<{ status: string; saved: number }>( + `/projects/${projectId}/drainage/boundary`, + { method: "PUT", body: JSON.stringify({ points }) }, + ); +} diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py new file mode 100644 index 00000000..4b75de22 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py @@ -0,0 +1,27 @@ +"""배수 관경 산정. + +유역을 나누는 최종 목적은 각 지점의 파이프 관경 결정이다. 유역 경사면에 100년 강우빈도를 +적용해 모이는 물의 양을 산정하고 그 유량으로 관경을 정한다. + +노선 기하(정점·누가거리·세류 교차점)는 `common_util_route_geometry`로 옮겼다 — B04 분석과 +B05 세부 설계가 같은 표현을 써야 하기 때문이다(2026-07-31 구조 개편). +""" + +from __future__ import annotations + + +def estimate_pipe_diameter_mm( + area_m2: float, + relief_m: float, + flow_length_m: float, + rainfall_mm_per_hour: float | None = None, +) -> float | None: + """유역 제원으로 배수 파이프 관경(mm)을 산정한다. + + 100년 강우빈도와 유역 경사면을 곱해 유출량을 구하고, 그 유량으로 관경을 정하는 것이 + 목적이다. **수식은 아직 확정되지 않았다** — 사용자가 로직을 제공하면 여기를 채운다. + 그때까지는 None을 돌려 호출부가 "미정"으로 표기하게 한다. + """ + # TODO(사용자 로직 대기): 100년 강우강도 × 유역면적 × 유출계수 → 유량 Q → 관경 D 산정. + _ = (area_m2, relief_m, flow_length_m, rainfall_mm_per_hour) + return None diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py new file mode 100644 index 00000000..4e0dbaef --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py @@ -0,0 +1,502 @@ +"""배수유역 세부 설계 (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 +from typing import Any + +import numpy as np + +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Export import STAGES +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 B05_wf2_Route.B05_wf2_Route_Engine_Drainage_Store import b05_drainage_dir, sync_from_b04 +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 + # B04가 계산해 둔 평균 흐름 화살표를 그대로 넘긴다 — B05는 다시 계산하지 않는다. + flow_arrows: list[list[Any]] = field(default_factory=list) + arrow_spacing_m: float = 0.0 + # 유역 안쪽 상류 세류망(WGS84 lon/lat 조각들). 화면 강조 표시용 — 계산에는 쓰지 않는다. + upstream_lonlat: list[list[list[float]]] = field(default_factory=list) + + +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, + flow_arrows=routing.flow_arrows, + arrow_spacing_m=routing.arrow_spacing_m, + upstream_lonlat=load_upstream_lines(stored_path), + ) + 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) + # 평균 흐름 화살표 — [x, y, 방위(도), 도로도달, 셀 수]. B04가 계산해 둔 그대로. + flow_arrows: list[list[Any]] = field(default_factory=list) + arrow_spacing_m: float = 0.0 + + @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: + """`03_road_routing` 산출물을 읽는다. 없으면 None. + + 읽는 대상은 B04 원본이 아니라 **B05 사본**이다. B04가 다시 해석했으면 사본을 먼저 + 갱신한다 — 편집분(`boundary_overrides.json`)은 사본 갱신과 무관하게 남는다. + """ + sync_from_b04(stored_path) + directory = b05_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 + routing.arrow_spacing_m = float( + (document.get("properties") or {}).get("arrow_spacing_m") or 0.0 + ) + 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 == "flow_arrow" and geometry.get("type") == "Point": + # 화면이 미터로 그리므로 속성의 x·y를 쓴다(기하는 저장 규약상 lon/lat). + routing.flow_arrows.append( + [ + float(properties.get("x") or 0.0), + float(properties.get("y") or 0.0), + float(properties.get("azimuth_deg") or 0.0), + bool(properties.get("reaches_road")), + int(properties.get("cells") or 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 load_upstream_lines(stored_path: str) -> list[list[list[float]]]: + """`01_primary_region`에서 상류 세류망만 읽는다(화면 강조용). + + 유역 판정의 기준선이라 B04 오버레이에서도 같은 선을 굵게 그린다 — B05는 그 선을 + 그대로 받아 표시만 한다. + """ + path = b05_drainage_dir(stored_path) / f"{STAGES['primary_region']}_primary_region.geojson" + if not path.exists(): + return [] + try: + with path.open("r", encoding="utf-8") as file: + document = json.load(file) + except (OSError, json.JSONDecodeError): + logger.warning("배수유역: 상류 세류망을 읽지 못했습니다 (%s).", path) + return [] + lines: list[list[list[float]]] = [] + for feature in document.get("features", []): + properties = feature.get("properties") or {} + geometry = feature.get("geometry") or {} + if properties.get("kind") != "upstream": + continue + coordinates = geometry.get("coordinates") + if geometry.get("type") == "LineString" and coordinates: + lines.append(coordinates) + elif geometry.get("type") == "MultiLineString" and coordinates: + lines.extend(part for part in coordinates if part) + return lines + + +# ── ⑨ 관 최소 개수 보충 ───────────────────────────────────────────────────── + + +def place_pipes( + vertices: list[RouteVertex], + base_pipes: list[StructureCandidate], + strength_curve: np.ndarray, +) -> list[StructureCandidate]: + """B04가 정한 기본 관(세류 교차점)에, 최대 간격을 넘는 구간만 최소 개수로 보충한다. + + 기본 관은 여기서 다시 찾지 않는다 — B04 산출물에 이미 들어 있다. + """ + total_length = vertices[-1].chainage_m + base: list[StructureCandidate] = [] + for candidate in sorted(base_pipes, key=lambda item: item.chainage_m): + if base and candidate.chainage_m - base[-1].chainage_m < DRAINAGE_PIPE_MIN_SPACING_M: + continue + base.append(candidate) + + filled: list[StructureCandidate] = [] + previous = 0.0 + for candidate in [*base, None]: + boundary = candidate.chainage_m if candidate else total_length + filled.extend(_fill_gap(vertices, strength_curve, previous, boundary)) + if candidate: + filled.append(candidate) + previous = candidate.chainage_m + else: + previous = boundary + filled.sort(key=lambda item: item.chainage_m) + return filled + + +def _fill_gap( + vertices: list[RouteVertex], + strength_curve: np.ndarray, + start_m: float, + end_m: float, +) -> list[StructureCandidate]: + """[start, end] 구간에 최대 간격을 지키는 **최소 개수**의 관을 배치한다. + + 필요 개수 n은 구간 길이로 정해지고(ceil(L/max) − 1), 각 관은 등분 위치를 중심으로 + 허용 여유(slack) 안에서만 움직인다. 그래서 개수는 늘지 않으면서도 흐름 강도가 크고 + 종단이 낮은 지점으로 붙는다. + """ + span = end_m - start_m + if span <= DRAINAGE_PIPE_MAX_SPACING_M: + return [] + count = int(np.ceil(span / DRAINAGE_PIPE_MAX_SPACING_M)) - 1 + if count <= 0: + return [] + spacing = span / (count + 1) + slack = max(0.0, (DRAINAGE_PIPE_MAX_SPACING_M - spacing) / 2.0) + placed: list[StructureCandidate] = [] + for order in range(1, count + 1): + nominal = start_m + spacing * order + low = max(start_m + DRAINAGE_PIPE_MIN_SPACING_M, nominal - slack) + high = min(end_m - DRAINAGE_PIPE_MIN_SPACING_M, nominal + slack) + chosen = _best_position(vertices, strength_curve, low, high, nominal) + x, y, _ = interpolate_vertex(vertices, chosen) + placed.append(StructureCandidate(chainage_m=chosen, x=x, y=y, reason="spacing")) + return placed + + +def _best_position( + vertices: list[RouteVertex], + strength_curve: np.ndarray, + low_m: float, + high_m: float, + fallback_m: float, +) -> float: + """허용 구간 안에서 흐름 강도가 크고 종단이 낮은 위치를 고른다.""" + if high_m <= low_m: + return fallback_m + positions = np.arange(low_m, high_m + 1.0, 1.0) + if positions.size == 0: + return fallback_m + index = np.clip(np.round(positions).astype(np.int64), 0, strength_curve.size - 1) + strength = strength_curve[index] + heights = np.array([interpolate_vertex(vertices, float(p))[2] for p in positions]) + + strength_score = strength / strength.max() if strength.max() > 0 else np.zeros_like(strength) + height_span = float(heights.max() - heights.min()) + sag_score = ( + (heights.max() - heights) / height_span if height_span > 1e-6 else np.zeros_like(heights) + ) + score = _SCORE_WEIGHT_STRENGTH * strength_score + _SCORE_WEIGHT_SAG * sag_score + for order, position in enumerate(positions): + if not is_uphill_at(vertices, float(position)): + score[order] *= _SCORE_FILL_PENALTY + return float(positions[int(np.argmax(score))]) + + +def _pipes_from_chainages( + vertices: list[RouteVertex], chainages: list[float] +) -> list[StructureCandidate]: + """사용자가 확정·편집한 누가거리 목록을 관 후보로 되돌린다. + + 노선 밖 값은 시·종점으로 당긴다. 그대로 두면 마커는 끝점에 찍히는데 라벨만 −50m처럼 + 나와 좌표와 표기가 어긋난다. + """ + total_length = vertices[-1].chainage_m + clamped = {min(max(round(float(item), 2), 0.0), total_length) for item in chainages} + pipes: list[StructureCandidate] = [] + for value in sorted(clamped): + x, y, _ = interpolate_vertex(vertices, value) + pipes.append(StructureCandidate(chainage_m=value, x=x, y=y, reason="confirmed")) + return pipes + + +# ── ⑩ 측구 흐름으로 도로 셀 → 담당 관 ─────────────────────────────────────── + + +def assign_road_cells_to_pipes( + vertices: list[RouteVertex], + pipes: list[StructureCandidate], + road_chainage: np.ndarray, +) -> np.ndarray: + """도로 셀마다 물이 실제로 흘러가는 담당 관 번호를 정한다. + + 노면 물은 측구를 타고 종단 내리막으로 흐르므로, 종단 계획선을 1차원 지형으로 보고 + 같은 방식(내리막 추적 + 관에서 흡수)으로 푼다. 관이 없는 사그(저점)에 갇힌 구간은 + 가장 가까운 관이 받는 것으로 본다. + """ + total_length = vertices[-1].chainage_m + step = max(DRAINAGE_DITCH_SAMPLE_M, 0.5) + stations = np.arange(0.0, total_length + step, step) + heights = np.array([interpolate_vertex(vertices, float(s))[2] for s in stations]) + pipe_chainages = np.array([pipe.chainage_m for pipe in pipes]) + pipe_station = np.clip(np.round(pipe_chainages / step).astype(np.int64), 0, stations.size - 1) + + # 앞뒤 이웃 중 더 낮은 쪽으로 흘려보낸다(양쪽 다 높으면 사그 = 제자리). + back_z = np.full(stations.size, np.inf) + back_z[1:] = heights[:-1] + forward_z = np.full(stations.size, np.inf) + forward_z[:-1] = heights[1:] + go_back = (back_z < heights) & (back_z <= forward_z) + go_forward = (forward_z < heights) & ~go_back + receiver = np.arange(stations.size, dtype=np.int64) + receiver[go_back] -= 1 + receiver[go_forward] += 1 + receiver[pipe_station] = pipe_station # 관은 물을 흡수한다 + + owner = np.full(stations.size, -1, dtype=np.int64) + owner[pipe_station] = np.arange(pipe_chainages.size) + jump = receiver + for _ in range(40): + next_jump = jump[jump] + if np.array_equal(next_jump, jump): + break + jump = next_jump + resolved = owner[jump] + # 관 없는 사그에 갇힌 구간은 가장 가까운 관에 붙인다. + orphan = resolved < 0 + if orphan.any() and pipe_chainages.size: + nearest = np.abs(stations[orphan, None] - pipe_chainages[None, :]).argmin(axis=1) + resolved[orphan] = nearest + + slot_station = np.clip(np.round(road_chainage / step).astype(np.int64), 0, stations.size - 1) + return resolved[slot_station].astype(np.int32) + + +# ── ⑩ 세부유역 조립 ──────────────────────────────────────────────────────── + + +def assemble_basins( + solution: RoadRouting, + pipes: list[StructureCandidate], + pipe_of_slot: np.ndarray, +) -> list[WatershedBasin]: + """셀이 도달한 도로 셀의 담당 관을 그대로 유역 번호로 삼아 세부유역을 만든다.""" + spec = solution.spec + labels = np.full(spec.size, -1, dtype=np.int32) + reached = solution.road_slot >= 0 + labels[reached] = pipe_of_slot[solution.road_slot[reached]] + + polygons = polygonize_labels(spec, labels) + cell_area = spec.cell_area_m2 + basins: list[WatershedBasin] = [] + for order, pipe in enumerate(pipes): + member = labels == order + count = int(member.sum()) + if count == 0: + continue + geometry = polygons.get(order) + elevations = solution.elevation[member] + highest = float(np.nanmax(elevations)) if np.isfinite(elevations).any() else 0.0 + outlet_z = _outlet_elevation(solution, order, pipe_of_slot) + area = count * cell_area + relief = max(0.0, highest - outlet_z) + flow_length = float(solution.path_length[member].max()) + basins.append( + WatershedBasin( + index=len(basins) + 1, + chainage_m=pipe.chainage_m, + outlet_x=pipe.x, + outlet_y=pipe.y, + boundary_xy=largest_ring(geometry) if geometry is not None else [], + area_m2=area, + relief_m=relief, + flow_length_m=flow_length, + pipe_diameter_mm=estimate_pipe_diameter_mm(area, relief, flow_length), + ) + ) + return basins + + +def _outlet_elevation(solution: RoadRouting, pipe_order: int, pipe_of_slot: np.ndarray) -> float: + """관이 담당하는 도로 셀들의 최저 표고 = 유역 출구 표고.""" + slots = np.flatnonzero(pipe_of_slot == pipe_order) + if slots.size == 0: + return 0.0 + elevations = solution.elevation[solution.road_cell_index[slots]] + finite = elevations[np.isfinite(elevations)] + return float(finite.min()) if finite.size else 0.0 diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Store.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Store.py new file mode 100644 index 00000000..1ee4e508 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Store.py @@ -0,0 +1,245 @@ +"""B05 전용 배수유역 저장소 (사본 관리 + 유역 외곽선 편집분 보존). + +B04는 배수유역을 해석해 `B04_wf1_Surface/drainage/`에 남긴다. B05는 그 결과를 읽어 관을 +보충하고 세부유역을 나누는데, 같은 폴더를 그대로 쓰면 B05에서 손댄 내용이 B04 원본을 +덮어쓴다. 그래서 여기서 사본을 따로 둔다(2026-08-01 사용자 지시). + + · `B05_wf2_Route/drainage/` — B04 산출물의 사본. B04가 다시 해석하면 자동으로 갱신된다. + · `boundary_overrides.json` — 사용자가 옮긴 유역 외곽선 포인트만. 사본이 갱신돼도 남는다. + +노선이 바뀌지 않으면 유역도 바뀌지 않는다. 노선이 바뀌어 B04가 재계산하면 사본은 새 결과로 +덮어쓰고, 저장해 둔 편집 포인트는 좌표 근접으로 새 외곽선에 다시 붙인다. 새 유역 **안쪽**으로 +들어간 포인트는 경계를 넓히는 의미가 없으므로 버린다. +""" + +from __future__ import annotations + +import json +import logging +import math +import shutil +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Export import drainage_dir +from common_util.common_util_storage import resolve_stored_project_path +from config.config_system import ( + DRAINAGE_B05_DIRNAME, + DRAINAGE_BOUNDARY_HANDLE_SPACING_M, + DRAINAGE_BOUNDARY_MATCH_RADIUS_M, + DRAINAGE_BOUNDARY_OVERRIDE_FILENAME, +) + +logger = logging.getLogger(__name__) + +# 위도 1도 ≈ 110540m, 경도 1도 ≈ 111320m·cos(위도). 30m 안팎의 근접 판정에는 충분하다. +_METERS_PER_LAT_DEGREE = 110540.0 +_METERS_PER_LON_DEGREE = 111320.0 + +LonLatPoint = list[float] + + +@dataclass +class BoundaryOverride: + """사용자가 옮긴 외곽선 포인트 하나 — 원래 자리(base)와 옮긴 자리(moved).""" + + base: tuple[float, float] + moved: tuple[float, float] + + +def b05_drainage_dir(stored_path: str) -> Path: + """B05 전용 배수유역 폴더. B04 원본과 분리된 사본이 여기 들어간다.""" + return Path(resolve_stored_project_path(stored_path)) / "B05_wf2_Route" / DRAINAGE_B05_DIRNAME + + +def sync_from_b04(stored_path: str) -> bool: + """B04 산출물을 B05 사본으로 맞춘다. 실제로 복사했으면 True. + + 사본이 없으면 초안으로 1회 복사하고, B04 쪽이 더 최신이면(재해석) 그 파일만 덮어쓴다. + `boundary_overrides.json`은 B04에 없는 파일이라 이 과정에서 손대지 않는다. + """ + source = drainage_dir(stored_path) + if not source.is_dir(): + return False + target = b05_drainage_dir(stored_path) + copied = 0 + try: + target.mkdir(parents=True, exist_ok=True) + for item in source.iterdir(): + if not item.is_file(): + continue + destination = target / item.name + if destination.exists() and destination.stat().st_mtime >= item.stat().st_mtime: + continue + shutil.copy2(item, destination) + copied += 1 + except OSError: + logger.warning("배수유역: B05 사본 갱신 실패 (%s → %s)", source, target) + return False + if copied: + logger.info("배수유역: B05 사본 갱신 — %d개 파일 (%s)", copied, target) + return copied > 0 + + +def _overrides_path(stored_path: str) -> Path: + return b05_drainage_dir(stored_path) / DRAINAGE_BOUNDARY_OVERRIDE_FILENAME + + +def load_boundary_overrides(stored_path: str) -> list[BoundaryOverride]: + """저장된 외곽선 편집 포인트를 읽는다. 없으면 빈 목록.""" + path = _overrides_path(stored_path) + if not path.exists(): + return [] + try: + with path.open("r", encoding="utf-8") as file: + document = json.load(file) + except (OSError, json.JSONDecodeError): + logger.warning("배수유역: 외곽선 편집 파일을 읽지 못했습니다 (%s).", path) + return [] + overrides: list[BoundaryOverride] = [] + for entry in (document or {}).get("points", []): + base = _as_point(entry.get("base")) + moved = _as_point(entry.get("moved")) + if base and moved: + overrides.append(BoundaryOverride(base=base, moved=moved)) + return overrides + + +def save_boundary_overrides(stored_path: str, overrides: list[BoundaryOverride]) -> int: + """외곽선 편집 포인트를 저장한다. 저장된 개수를 돌려준다.""" + path = _overrides_path(stored_path) + document = { + "version": 1, + "spacing_m": DRAINAGE_BOUNDARY_HANDLE_SPACING_M, + "points": [{"base": list(item.base), "moved": list(item.moved)} for item in overrides], + } + try: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as file: + json.dump(document, file, ensure_ascii=False) + except OSError: + logger.warning("배수유역: 외곽선 편집 저장 실패 (%s).", path) + return 0 + logger.info("배수유역: 외곽선 편집 %d개 저장 — %s", len(overrides), path) + return len(overrides) + + +def parse_overrides(values: Any) -> list[BoundaryOverride]: + """프론트가 보낸 편집 포인트 목록을 정리한다(형식이 어긋난 항목은 버린다).""" + if not isinstance(values, list): + return [] + parsed: list[BoundaryOverride] = [] + for entry in values: + if not isinstance(entry, dict): + continue + base = _as_point(entry.get("base")) + moved = _as_point(entry.get("moved")) + if base and moved: + parsed.append(BoundaryOverride(base=base, moved=moved)) + return parsed + + +def _as_point(value: Any) -> tuple[float, float] | None: + if not isinstance(value, (list, tuple)) or len(value) < 2: + return None + try: + return (float(value[0]), float(value[1])) + except (TypeError, ValueError): + return None + + +def resample_boundary( + polygon_lonlat: list[LonLatPoint], spacing_m: float = DRAINAGE_BOUNDARY_HANDLE_SPACING_M +) -> list[LonLatPoint]: + """외곽선을 일정 간격(m)으로 다시 찍어 편집 핸들 목록을 만든다. + + 격자 경계라 원래 정점이 1m 간격으로 촘촘해 그대로 핸들로 쓸 수 없다. + """ + points = [p for p in polygon_lonlat if isinstance(p, (list, tuple)) and len(p) >= 2] + if len(points) < 3 or spacing_m <= 0: + return [list(p[:2]) for p in points] + + # 폐합 고리로 다룬다 — 끝점이 시작점과 같으면 중복을 뺀다. + ring = [(float(p[0]), float(p[1])) for p in points] + if _distance_m(ring[0], ring[-1]) < 0.001: + ring = ring[:-1] + if len(ring) < 3: + return [list(p) for p in ring] + + handles: list[LonLatPoint] = [list(ring[0])] + carried = 0.0 + for index in range(len(ring)): + start = ring[index] + end = ring[(index + 1) % len(ring)] + segment = _distance_m(start, end) + if segment <= 0: + continue + position = spacing_m - carried + while position <= segment: + ratio = position / segment + handles.append( + [ + start[0] + (end[0] - start[0]) * ratio, + start[1] + (end[1] - start[1]) * ratio, + ] + ) + position += spacing_m + carried = (carried + segment) % spacing_m + return handles + + +def apply_boundary_overrides( + handles: list[LonLatPoint], overrides: list[BoundaryOverride] +) -> tuple[list[LonLatPoint], list[BoundaryOverride]]: + """저장된 편집 포인트를 새 핸들 목록에 다시 붙인다. + + · 인덱스가 아니라 **좌표 근접**으로 맞춘다 — 재계산하면 핸들 수가 달라진다. + · 옮긴 자리가 새 유역 **안쪽**이면 경계를 넓히지 않으므로 버린다. + 돌려주는 값은 (편집이 반영된 핸들, 살아남은 편집 목록). + """ + if not overrides or len(handles) < 3: + return handles, list(overrides) + + polygon = [(float(p[0]), float(p[1])) for p in handles] + applied = [list(p) for p in handles] + kept: list[BoundaryOverride] = [] + for override in overrides: + if _point_in_polygon(override.moved, polygon): + continue + nearest = -1 + nearest_distance = DRAINAGE_BOUNDARY_MATCH_RADIUS_M + for index, point in enumerate(polygon): + distance = _distance_m(override.base, point) + if distance <= nearest_distance: + nearest = index + nearest_distance = distance + if nearest < 0: + continue + # 붙인 자리를 새 base로 잡아 둔다 — 다음 재계산에서도 같은 지점에 다시 붙는다. + applied[nearest] = [override.moved[0], override.moved[1]] + kept.append(BoundaryOverride(base=polygon[nearest], moved=override.moved)) + return applied, kept + + +def _distance_m(a: tuple[float, float], b: tuple[float, float]) -> float: + """두 lon/lat 사이 거리(m) 근사. 수십 m 범위 판정에만 쓴다.""" + mean_lat = math.radians((a[1] + b[1]) / 2) + dx = (b[0] - a[0]) * _METERS_PER_LON_DEGREE * math.cos(mean_lat) + dy = (b[1] - a[1]) * _METERS_PER_LAT_DEGREE + return math.hypot(dx, dy) + + +def _point_in_polygon(point: tuple[float, float], polygon: list[tuple[float, float]]) -> bool: + """레이 캐스팅 내부 판정 (lon/lat 평면에서 그대로 계산).""" + x, y = point + inside = False + count = len(polygon) + for index in range(count): + x1, y1 = polygon[index] + x2, y2 = polygon[(index + 1) % count] + if (y1 > y) != (y2 > y): + crossing = x1 + (y - y1) * (x2 - x1) / (y2 - y1) + if crossing > x: + inside = not inside + return inside diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py new file mode 100644 index 00000000..72e52e1e --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py @@ -0,0 +1,193 @@ +"""배수유역 세부 설계 API 라우터 (B05 — 일반 사용자용). + +**분석하지 않는다.** B04가 미리 돌려 저장한 결과를 읽어 관을 보충하고 세부유역만 나눈다. +격자 해석은 30초가 걸려 일반 사용자를 붙잡아 두므로 여기서는 아예 돌리지 않는다 +(2026-07-31 사용자 지시). + +좌표는 사업지 CRS(m)에서 계산하고 응답만 WGS84로 바꿔 내보낸다. +""" + +import asyncio +import logging +from typing import Any +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pyproj import Transformer + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B05_wf2_Route.B05_wf2_Route_Engine_Drainage_Basin import build_drainage_detail +from B05_wf2_Route.B05_wf2_Route_Engine_Drainage_Store import ( + apply_boundary_overrides, + load_boundary_overrides, + parse_overrides, + resample_boundary, + save_boundary_overrides, +) +from B05_wf2_Route.B05_wf2_Route_Repository import ( + get_latest_route, + get_route_points, + get_surface_crs_epsg, +) +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_BOUNDARY_HANDLE_SPACING_M + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B05 Route Drainage"]) + + +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: + """확정 노선과 좌표 변환기를 준비한다. 도엽 피처는 읽지 않는다(분석을 안 하므로).""" + pool = get_db_pool() + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + route = await get_latest_route(connection, project_id) + if not route: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "확정된 경로가 없습니다."}, + ) + points = await get_route_points(connection, int(route["id"])) + surface_model_id = route.get("surface_model_id") + epsg = await get_surface_crs_epsg( + connection, project_id, int(surface_model_id) if surface_model_id else 0 + ) + + vertices = build_route_vertices(points) + if len(vertices) < 2: + return JSONResponse( + status_code=400, + content={"status": "error", "message": "노선 좌표가 부족합니다."}, + ) + transformer = Transformer.from_crs(f"EPSG:{epsg or 5186}", "EPSG:4326", always_xy=True) + return { + "route_id": int(route["id"]), + "vertices": vertices, + "stored_path": stored_path, + "to_lonlat": lambda x, y: transformer.transform(x, y), + } + + +@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`(누가거리 목록)를 주면 그 위치로 관을 확정하고(사용자 편집), + 없으면 B04의 기본 관에 최대 간격 규칙으로 최소 개수만 보충한다. + """ + prepared = await _prepare(project_id) + if isinstance(prepared, JSONResponse): + return prepared + raw = (payload or {}).get("chainages") + confirmed = _parse_chainages(raw) if isinstance(raw, list) else [] + + 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"] + # 2차 전체 유역 외곽선 — 편집 핸들 간격으로 다시 찍고 저장된 편집분을 얹는다. + # 격자 경계라 원래 정점이 1m 간격이라 그대로는 손으로 잡을 수 없다. + boundary = resample_boundary(detail.basin_lonlat) + stored_overrides = load_boundary_overrides(prepared["stored_path"]) + boundary, kept = apply_boundary_overrides(boundary, stored_overrides) + dropped = len(stored_overrides) - len(kept) + if dropped > 0: + # 새 유역 안쪽으로 들어갔거나 붙일 자리가 없어진 편집분은 파일에서도 지운다. + save_boundary_overrides(prepared["stored_path"], kept) + + return { + "status": "success", + "project_id": str(project_id), + "route_id": prepared["route_id"], + # B04가 남긴 그대로 — 계획도로선. 외곽선만 편집분을 반영해 내보낸다. + "route_lonlat": detail.route_lonlat, + "main_polygon_lonlat": boundary, + "boundary_spacing_m": DRAINAGE_BOUNDARY_HANDLE_SPACING_M, + "boundary_overrides": [ + {"base": list(item.base), "moved": list(item.moved)} for item in kept + ], + "boundary_dropped": dropped, + "grid_cell_m": detail.grid_cell_m, + # 평균 흐름 화살표 — B04가 계산해 저장한 것을 그대로 넘긴다(사업지 CRS m). + "flow_arrows": detail.flow_arrows, + "arrow_spacing_m": detail.arrow_spacing_m, + # 유역 안쪽 상류 세류망 — 화면 강조 토글용(WGS84). + "upstream_lonlat": detail.upstream_lonlat, + # 계획선 위 배관 지점 — 유역이 없는 관도 마커로 표시해야 하므로 별도 목록. + "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이면 프론트가 "미정"으로 표기한다. + "pipe_diameter_mm": basin.pipe_diameter_mm, + } + for basin in detail.basins + ], + } + + +@router.put("/{project_id}/drainage/boundary", response_model=None) +async def put_drainage_boundary( + project_id: UUID, + payload: dict[str, Any] | None = None, +) -> 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) + if not stored_path: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 경로가 없습니다."}, + ) + overrides = parse_overrides((payload or {}).get("points")) + saved = save_boundary_overrides(stored_path, overrides) + return {"status": "success", "project_id": str(project_id), "saved": saved} + + +def _parse_chainages(values: list[Any]) -> list[float]: + """사용자가 확정·편집한 누가거리 목록을 숫자로 정리한다.""" + parsed: list[float] = [] + for value in values: + try: + parsed.append(float(value)) + except (TypeError, ValueError): + continue + return parsed diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Boundary.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Boundary.ts new file mode 100644 index 00000000..747930a3 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Boundary.ts @@ -0,0 +1,188 @@ +import type { Normalizer, ViewState } from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender"; + +/* ============================================================================= + * 2차 전체 배수유역 외곽선 편집 (B05 배수유역도) + * + * 백엔드가 외곽선을 일정 간격(기본 20m)으로 다시 찍어 주면, 그 점들을 잡아 끌어 + * 유역 경계를 손으로 고친다. 옮긴 값은 **화면에만** 들고 있다가 종단 경로 확정 시 + * 사용자가 승인하면 옮긴 포인트만 저장한다(2026-08-01 사용자 지시). + * + * 저장 대상은 폴리곤 전체가 아니라 `{원래 자리, 옮긴 자리}` 짝이다 — 노선이 바뀌어 + * 유역을 다시 계산하면 점 개수가 달라지므로 좌표 근접으로 다시 붙여야 하기 때문이다. + * ========================================================================== */ + +/** 편집 핸들 반경(px)과 잡을 수 있는 여유. */ +const HANDLE_RADIUS_PX = 4; +const HANDLE_HIT_PX = 9; +const HANDLE_COLOR = "rgba(146, 64, 14, 0.95)"; +const HANDLE_MOVED_COLOR = "rgba(220, 38, 38, 0.95)"; +/** 같은 자리로 볼 오차(도). 대략 0.1m 수준. */ +const SAME_POINT_EPSILON = 1e-6; + +export type LonLat = [number, number]; + +export interface BoundaryOverrideEntry { + /** 재계산된 외곽선 위의 원래 자리. 다음 계산에서 이 좌표로 다시 붙인다. */ + base: LonLat; + /** 사용자가 옮긴 자리. */ + moved: LonLat; +} + +export interface BoundaryEditor { + /** 서버가 준 외곽선(편집 반영분)과 저장돼 있던 편집 목록을 싣는다. */ + setBoundary: ( + points: ReadonlyArray, + overrides: ReadonlyArray, + ) => void; + /** 현재 화면에 그릴 외곽선. */ + points: () => LonLat[]; + /** 저장 대상 — 원래 자리와 다른 포인트만. */ + overrides: () => BoundaryOverrideEntry[]; + /** 이번 화면에서 사용자가 옮긴 것이 있는지(저장 여부를 물어볼 근거). */ + isDirty: () => boolean; + markSaved: () => void; + setEditMode: (on: boolean) => void; + /** 편집 모드에서 핸들을 그린다. 외곽선 자체는 패널이 능선 스타일로 그린다. */ + draw: (context: CanvasRenderingContext2D, normalizer: Normalizer, view: ViewState) => void; + /** 핸들을 잡았으면 true — 지도 팬을 시작하지 않는다. */ + handleDown: (normalizer: Normalizer, view: ViewState, x: number, y: number) => boolean; + /** 드래그 중이면 true. */ + handleMove: (normalizer: Normalizer, view: ViewState, x: number, y: number) => boolean; + handleUp: () => void; +} + +type Affine = { ax: number; bx: number; ay: number; by: number }; + +/** 지도 렌더러와 같은 화면 변환. (MapRender 내부 계산과 동일 식) */ +function affineOf(view: ViewState): Affine { + return { + ax: view.mapRect.width * view.scale, + bx: view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX, + ay: view.mapRect.height * view.scale, + by: view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY, + }; +} + +function toScreen(point: LonLat, normalizer: Normalizer, affine: Affine): [number, number] { + const nx = (point[0] - normalizer.lonMin) / normalizer.lonRange; + const ny = 1 - (point[1] - normalizer.latMin) / normalizer.latRange; + return [nx * affine.ax + affine.bx, ny * affine.ay + affine.by]; +} + +function toLonLat(x: number, y: number, normalizer: Normalizer, affine: Affine): LonLat | null { + if (!(affine.ax > 0) || !(affine.ay > 0)) return null; + const nx = (x - affine.bx) / affine.ax; + const ny = (y - affine.by) / affine.ay; + return [ + normalizer.lonMin + nx * normalizer.lonRange, + normalizer.latMin + (1 - ny) * normalizer.latRange, + ]; +} + +function samePoint(a: LonLat, b: LonLat): boolean { + return Math.abs(a[0] - b[0]) < SAME_POINT_EPSILON && Math.abs(a[1] - b[1]) < SAME_POINT_EPSILON; +} + +export function createBoundaryEditor(onChange: () => void): BoundaryEditor { + // 화면에 그리는 현재 외곽선. + let points: LonLat[] = []; + // 같은 인덱스의 "원래 자리". 저장분이 있는 핸들은 서버가 준 base를 그대로 쓴다. + let bases: LonLat[] = []; + let editMode = false; + let dragIndex: number | null = null; + let dirty = false; + + function setBoundary( + nextPoints: ReadonlyArray, + nextOverrides: ReadonlyArray, + ): void { + points = nextPoints.map((point) => [point[0], point[1]] as LonLat); + bases = points.map((point) => [point[0], point[1]] as LonLat); + // 저장분이 반영된 자리는 원래 자리를 서버 값으로 되돌려 둔다 — 다음 재계산에서 + // 옮긴 자리가 아니라 외곽선 위 원래 자리로 다시 붙어야 하기 때문이다. + nextOverrides.forEach((override) => { + const index = points.findIndex((point) => samePoint(point, override.moved)); + if (index >= 0) bases[index] = [override.base[0], override.base[1]]; + }); + dragIndex = null; + dirty = false; + } + + function overrides(): BoundaryOverrideEntry[] { + const list: BoundaryOverrideEntry[] = []; + points.forEach((point, index) => { + const base = bases[index]; + if (!base || samePoint(point, base)) return; + list.push({ base: [base[0], base[1]], moved: [point[0], point[1]] }); + }); + return list; + } + + function draw(context: CanvasRenderingContext2D, normalizer: Normalizer, view: ViewState): void { + if (!editMode || points.length === 0) return; + const affine = affineOf(view); + context.save(); + context.lineWidth = 1.2; + context.strokeStyle = "rgba(255, 255, 255, 0.9)"; + points.forEach((point, index) => { + const [x, y] = toScreen(point, normalizer, affine); + if (x < -20 || y < -20 || x > view.width + 20 || y > view.height + 20) return; + const base = bases[index]; + context.beginPath(); + context.arc(x, y, HANDLE_RADIUS_PX, 0, Math.PI * 2); + context.fillStyle = base && !samePoint(point, base) ? HANDLE_MOVED_COLOR : HANDLE_COLOR; + context.fill(); + context.stroke(); + }); + context.restore(); + } + + function handleDown(normalizer: Normalizer, view: ViewState, x: number, y: number): boolean { + if (!editMode || points.length === 0) return false; + const affine = affineOf(view); + let nearest = -1; + let nearestDistance = HANDLE_HIT_PX; + points.forEach((point, index) => { + const [px, py] = toScreen(point, normalizer, affine); + const distance = Math.hypot(px - x, py - y); + if (distance <= nearestDistance) { + nearest = index; + nearestDistance = distance; + } + }); + if (nearest < 0) return false; + dragIndex = nearest; + return true; + } + + function handleMove(normalizer: Normalizer, view: ViewState, x: number, y: number): boolean { + if (dragIndex === null) return false; + const moved = toLonLat(x, y, normalizer, affineOf(view)); + if (!moved) return true; + points[dragIndex] = moved; + dirty = true; + onChange(); + return true; + } + + return { + setBoundary, + points: () => points.map((point) => [point[0], point[1]] as LonLat), + overrides, + isDirty: () => dirty, + markSaved: () => { + dirty = false; + }, + setEditMode(on: boolean) { + editMode = on; + dragIndex = null; + onChange(); + }, + draw, + handleDown, + handleMove, + handleUp() { + dragIndex = null; + }, + }; +} diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts new file mode 100644 index 00000000..15006d2c --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -0,0 +1,681 @@ +import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; +import { DRAINAGE_SHEET_LAYERS, fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; +import { + fetchVWorldMeta, + getVWorldMapUrl, + type VWorldMeta, +} from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; +import { + computeMapRect, + computeRouteView, + createNormalizer, + drawFilledRing, + drawPreparedLayer, + drawRidgeRing, + drawUpstreamLines, + prepareLayer, + prepareMetricPolyline, + type GeoJsonCollection, + type MapRect, + type Normalizer, + type PreparedLayer, + type ViewState, +} from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender"; +import { + fetchDrainageBasins, + type DrainageBasin, + type RoutePoint, +} from "./B05_wf2_Route_Api_Fetch"; +import { drawFlowArrows, type FlowArrow } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows"; +import { + createBoundaryEditor, + type BoundaryOverrideEntry, +} from "./B05_wf2_Route_UI_Drainage_Boundary"; +import { createPipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes"; +import { createProgressCircle } from "@ui/ui_template_progress"; + +// 배수유역도 패널 — 하단 종단 패널 안쪽 우측에 도킹되는 2단 사이드 패널. +// 하단 패널이 닫히면 이 패널도 함께 화면에서 사라진다(부모 안에 들어 있으므로 자동). +// 지도는 B04에서 분리한 렌더 엔진(B04_wf1_Surface_UI_MapRender)을 그대로 재사용해 +// 사전 투영·LOD·뷰포트 컬링·커서 중심 줌 동작을 동일하게 얻는다. + +/** 배수유역 산정의 근거가 되는 도엽 레이어. 3D는 쓰지 않는다(사용자 지시). + * 표고점은 유효 데이터가 적어 산정에서 제외했으므로 배경에도 띄우지 않는다(2026-07-31). */ +const DRAINAGE_LAYERS = DRAINAGE_SHEET_LAYERS; +type DrainageLayer = (typeof DRAINAGE_LAYERS)[number]; + +const LAYER_COLORS: Record = { + 도엽_등고선: "#a5b4fc", + 도엽_하천중심선: "#2563eb", +}; + +const LAYER_LABELS: Record = { + 도엽_등고선: "등고선", + 도엽_하천중심선: "세류", +}; + +/** 도엽 레이어가 아닌 표시 토글의 띠 색 — 지도에 그려지는 선 색과 맞춘다. */ +const ARROW_TOGGLE_COLOR = "#7c3aed"; +const UPSTREAM_TOGGLE_COLOR = "#1d4ed8"; + +const ROUTE_COLOR = "#f97316"; +const COLLAPSED_KEY = "b05-route-drainage-collapsed"; + +/** 유역 오버레이 파스텔 색상. 번호 순으로 돌려쓴다(사용자 지시: 파스텔톤). */ +const BASIN_COLORS = [ + "rgba(167, 216, 199, 0.45)", + "rgba(247, 208, 168, 0.45)", + "rgba(186, 199, 240, 0.45)", + "rgba(241, 183, 199, 0.45)", + "rgba(214, 226, 168, 0.45)", + "rgba(202, 186, 227, 0.45)", + "rgba(168, 214, 232, 0.45)", + "rgba(240, 219, 168, 0.45)", +] as const; + +export interface DrainagePanel { + root: HTMLElement; + /** 프로젝트가 정해지면 배경지도·도엽 레이어를 불러온다. */ + load: (projectId: string) => void; + /** 확정된 노선 평면 선형(사업지 좌표계 m)을 지도 위에 겹친다. */ + setRoute: (points: ReadonlyArray) => void; + /** 이번 화면에서 사용자가 유역선 포인트를 옮겼는지(경로 확정 시 저장 여부를 묻는 근거). */ + hasBoundaryEdits: () => boolean; + /** 저장 대상 — 원래 자리와 옮긴 자리 짝. 폴리곤 전체가 아니다. */ + boundaryOverrides: () => BoundaryOverrideEntry[]; + /** 저장이 끝났음을 알린다(다시 묻지 않도록). */ + markBoundarySaved: () => void; + dispose: () => void; +} + +export function createDrainagePanel(): DrainagePanel { + const root = document.createElement("aside"); + root.className = "b05-drainage"; + const panelHandle = createWorkflowPanelHandle("side"); + + const header = document.createElement("div"); + header.className = "b05-drainage__header"; + const title = document.createElement("h3"); + title.textContent = "배수유역도"; + const layerButtons = document.createElement("div"); + 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 = + "B04에서 분석해 둔 배수유역을 불러와 관을 보충하고 세부유역을 나눕니다. " + + "분석 결과가 없으면 B04에서 먼저 실행해야 합니다."; + // 배관 편집 토글 — 켜면 계획선 클릭으로 배관 추가, 마커 드래그로 이동. + const editButton = document.createElement("button"); + editButton.type = "button"; + editButton.className = "b05-drainage__analyze b05-drainage__tool"; + editButton.textContent = "배관 편집"; + editButton.setAttribute("aria-pressed", "false"); + // 선택된 배관 삭제 — 편집 모드에서 마커를 선택해야 활성화된다. + const deleteButton = document.createElement("button"); + deleteButton.type = "button"; + deleteButton.className = "b05-drainage__analyze b05-drainage__tool"; + deleteButton.textContent = "선택 삭제"; + deleteButton.disabled = true; + // 자동 제안으로 되돌리기 — 편집한 배관 배치를 버리고 백엔드 자동 제안으로 재산정. + const autoButton = document.createElement("button"); + autoButton.type = "button"; + autoButton.className = "b05-drainage__analyze b05-drainage__tool"; + autoButton.textContent = "자동 제안"; + // 유역선 편집 토글 — 켜면 외곽선 위 핸들을 잡아 유역 경계를 손으로 고친다. + const boundaryButton = document.createElement("button"); + boundaryButton.type = "button"; + boundaryButton.className = "b05-drainage__analyze b05-drainage__tool"; + boundaryButton.textContent = "유역선 편집"; + boundaryButton.title = + "2차 전체 배수유역 외곽선 위 포인트를 끌어 경계를 고칩니다. " + + "옮긴 값은 종단 경로 확정 시 저장 여부를 묻습니다."; + boundaryButton.setAttribute("aria-pressed", "false"); + header.append(analyzeButton, editButton, deleteButton, autoButton, boundaryButton); + + const viewport = document.createElement("div"); + viewport.className = "b05-drainage__viewport"; + const backgroundImage = document.createElement("img"); + backgroundImage.className = "b05-drainage__image"; + backgroundImage.alt = "배경 위성지도"; + backgroundImage.draggable = false; + const canvas = document.createElement("canvas"); + canvas.className = "b05-drainage__canvas"; + const status = document.createElement("span"); + status.className = "b05-drainage__status"; + status.textContent = "노선을 확정하면 배수유역도가 표시됩니다."; + // 지도 정중앙 로딩 서클 — 배경도·도엽 레이어·유역 산정이 끝날 때까지 화면이 비어 보인다. + const progress = createProgressCircle({ overlay: true }); + progress.root.hidden = true; + viewport.append(backgroundImage, canvas, status, progress.root); + + /** 진행률(0~1, 모르면 null)과 문구. label이 null이면 서클을 감춘다. */ + function showProgress(ratio: number | null, label: string | null): void { + progress.root.hidden = label === null; + if (label !== null) progress.set(ratio, label); + } + // 유역 제원 목록(면적·표고·유하거리·관경). 관경 수식 미확정이라 당분간 "미정"으로 나온다. + const basinList = document.createElement("div"); + basinList.className = "b05-drainage__basins"; + basinList.hidden = true; + root.append(panelHandle.root, header, viewport, basinList); + + let projectId: string | null = null; + let meta: VWorldMeta | null = null; + const preparedLayers = new Map(); + const activeLayers = new Set(DRAINAGE_LAYERS); + let routeLayer: PreparedLayer | null = null; + let routePoints: ReadonlyArray = []; + let normalizer: Normalizer | null = null; + let basins: DrainageBasin[] = []; + let selectedBasin: number | null = null; + let editMode = false; + // 배관 편집기 — 마커 선택/추가/이동/삭제 시 재그리기와 버튼 상태만 갱신한다. + const pipeEditor = createPipeEditor(() => { + syncPipeSelection(); + scheduleDraw(); + }); + // 2차 전체 배수유역 외곽선 = 분수령. 별도 "능선" 레이어를 두지 않고 이 선 하나로 표시한다 + // (전체 유역 외곽선과 능선이 같은 선이므로 — 2026-07-31 사용자 지시). + let mainBoundary: Array<[number, number]> = []; + // 평균 흐름 화살표 — B04가 계산해 둔 것을 그대로 받아 그린다(여기서 계산하지 않는다). + let flowArrows: FlowArrow[] = []; + let arrowSpacingM = 0; + let showArrows = true; + // 유역 안쪽 상류 세류망 — B04가 채택한 기준선을 그대로 받아 강조만 한다. + let upstreamLines: Array> = []; + let showUpstream = true; + let boundaryMode = false; + const boundaryEditor = createBoundaryEditor(() => scheduleDraw()); + let scale = 1; + let offsetX = 0; + let offsetY = 0; + let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null; + let frameHandle = 0; + let loadSequence = 0; + let canvasWidth = 0; + let canvasHeight = 0; + let canvasDpr = 0; + + /** 제목 우측 표시 토글 — 등고선·세류·흐름 화살표·상류 세류가 모두 같은 양식을 쓴다. */ + function addLayerToggle( + label: string, + color: string, + initial: boolean, + onToggle: (next: boolean) => void, + title?: string, + ): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b05-drainage__layer-button" + (initial ? " is-active" : ""); + button.textContent = label; + button.style.setProperty("--b05-layer-color", color); + button.setAttribute("aria-pressed", String(initial)); + if (title) button.title = title; + let active = initial; + button.addEventListener("click", () => { + active = !active; + button.classList.toggle("is-active", active); + button.setAttribute("aria-pressed", String(active)); + onToggle(active); + scheduleDraw(); + }); + layerButtons.append(button); + return button; + } + + DRAINAGE_LAYERS.forEach((layer) => { + addLayerToggle(LAYER_LABELS[layer], LAYER_COLORS[layer], true, (next) => { + if (next) activeLayers.add(layer); + else activeLayers.delete(layer); + }); + }); + // 흐름 화살표 — 도면이 지저분해질 때 끄기 위한 토글(등고선·세류와 같은 줄·같은 양식). + addLayerToggle( + "흐름 화살표", + ARROW_TOGGLE_COLOR, + showArrows, + (next) => { + showArrows = next; + }, + "B04에서 산출한 평균 흐름 방향을 보이거나 숨깁니다.", + ); + // 상류 세류선 강조 — 유역 판정의 기준선이라 항상 같은 굵기·색으로 얹는다. + addLayerToggle( + "상류 세류", + UPSTREAM_TOGGLE_COLOR, + showUpstream, + (next) => { + showUpstream = next; + }, + "유역 안쪽 상류 세류망을 굵게 강조합니다.", + ); + + function updateImageTransform(): void { + backgroundImage.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; + } + + function draw(): void { + const rect = viewport.getBoundingClientRect(); + const width = Math.max(1, Math.floor(rect.width)); + const height = Math.max(1, Math.floor(rect.height)); + const dpr = window.devicePixelRatio || 1; + if (width !== canvasWidth || height !== canvasHeight || dpr !== canvasDpr) { + canvasWidth = width; + canvasHeight = height; + canvasDpr = dpr; + canvas.width = Math.floor(width * dpr); + canvas.height = Math.floor(height * dpr); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + } + const context = canvas.getContext("2d"); + if (!context) return; + context.setTransform(dpr, 0, 0, dpr, 0, 0); + context.clearRect(0, 0, width, height); + const mapRect: MapRect = computeMapRect(meta, width, height); + const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect }; + // 세부유역 채움을 가장 아래에 깔아 등고선·세류 판독을 가리지 않게 한다. + if (normalizer) { + basins.forEach((basin) => { + const color = BASIN_COLORS[(basin.index - 1) % BASIN_COLORS.length]; + drawFilledRing( + context, + { ring: basin.polygon_lonlat, label: String(basin.index) }, + normalizer!, + view, + selectedBasin === null || selectedBasin === basin.index + ? color + : color.replace(/0\.45\)$/, "0.18)"), + ); + }); + // 전체 유역 외곽선 = 분수령(능선). 세부유역 경계와 구분되게 파선 한 겹만 얹는다. + // 편집 중이면 사용자가 끌어 옮긴 외곽선을 그대로 보여 준다. + const boundary = boundaryEditor.points(); + if (boundary.length > 2) drawRidgeRing(context, boundary, normalizer, view); + } + // 등고선을 얇게 깔고 세류를 그 위에, 노선을 맨 위에 둔다. + DRAINAGE_LAYERS.forEach((layer) => { + if (!activeLayers.has(layer)) return; + const prepared = preparedLayers.get(layer); + if (!prepared) return; + context.lineWidth = layer === "도엽_등고선" ? 0.7 : 1.5; + context.strokeStyle = LAYER_COLORS[layer]; + drawPreparedLayer(context, prepared, view, "dot"); + }); + // 상류 세류선 강조 — 유역 채움 위, 흐름 화살표 아래(2026-08-01 사용자 지시). + // 그리기는 B04 오버레이와 같은 공용 렌더러를 쓴다. + if (showUpstream && normalizer && upstreamLines.length > 0) { + drawUpstreamLines(context, upstreamLines, normalizer, view); + } + if (routeLayer) { + context.lineWidth = 2.4; + context.strokeStyle = ROUTE_COLOR; + drawPreparedLayer(context, routeLayer, view, "dot"); + } + // 평균 흐름 화살표 — 유역 채움 위, 배관 마커 아래. 좌표는 사업지 CRS(m)라 + // 도엽 메타로 바로 화면에 옮긴다(배관 마커와 같은 변환). + if (showArrows && meta && flowArrows.length > 0) { + const spanX = meta.width_meters || 1; + const spanY = meta.height_meters || 1; + const pxPerMeter = (view.mapRect.width * view.scale) / spanX; + const ax = view.mapRect.width * view.scale; + const bx = view.mapRect.x * view.scale + (width / 2) * (1 - view.scale) + view.offsetX; + const ay = view.mapRect.height * view.scale; + const by = view.mapRect.y * view.scale + (height / 2) * (1 - view.scale) + view.offsetY; + const originX = meta.x_min; + const originY = meta.y_min; + drawFlowArrows( + context, + flowArrows, + arrowSpacingM, + pxPerMeter, + (x, y) => [((x - originX) / spanX) * ax + bx, (1 - (y - originY) / spanY) * ay + by], + view, + ); + } + // 유역선 편집 핸들 — 편집 모드에서만. 화살표 위, 배관 마커 아래. + if (normalizer) boundaryEditor.draw(context, normalizer, view); + // 배관(관 매설) 마커 — 계획선 위 최상단. + pipeEditor.draw(context, view, pipeColor); + updateImageTransform(); + } + + /** 현재 프레임 뷰 상태 (draw()와 동일 계산 — 배관 마커 포인터 히트 판정용). */ + function currentView(): ViewState { + const rect = viewport.getBoundingClientRect(); + const width = Math.max(1, Math.floor(rect.width)); + const height = Math.max(1, Math.floor(rect.height)); + return { width, height, scale, offsetX, offsetY, mapRect: computeMapRect(meta, width, height) }; + } + + /** 배관 마커 색 — 같은 누가거리 유역의 파스텔색(불투명). 유역이 없으면 회색. */ + function pipeColor(chainage: number): string { + const basin = basins.find((item) => Math.abs(item.chainage_m - chainage) < 0.51); + if (!basin) return "#e5e7eb"; + return BASIN_COLORS[(basin.index - 1) % BASIN_COLORS.length].replace(/0\.45\)$/, "1)"); + } + + /** 마커 선택 ↔ 유역 목록 선택 동기화 + 삭제 버튼 활성화. */ + function syncPipeSelection(): void { + const index = pipeEditor.selected(); + deleteButton.disabled = !editMode || index === null; + const pipe = index === null ? null : pipeEditor.pipes()[index]; + const basin = pipe + ? basins.find((item) => Math.abs(item.chainage_m - pipe.chainage_m) < 0.51) + : null; + selectedBasin = basin ? basin.index : null; + renderBasinList(); + } + + function scheduleDraw(): void { + if (frameHandle) return; + frameHandle = window.requestAnimationFrame(() => { + frameHandle = 0; + draw(); + }); + } + + /** 유역 제원 목록을 다시 그린다. 항목을 누르면 해당 유역만 진하게 강조한다. */ + function renderBasinList(): void { + basinList.textContent = ""; + basinList.hidden = basins.length === 0; + basins.forEach((basin) => { + const row = document.createElement("button"); + row.type = "button"; + row.className = "b05-drainage__basin" + (selectedBasin === basin.index ? " is-selected" : ""); + const badge = document.createElement("span"); + badge.className = "b05-drainage__basin-index"; + badge.textContent = String(basin.index); + badge.style.background = BASIN_COLORS[(basin.index - 1) % BASIN_COLORS.length]; + const metrics = document.createElement("span"); + metrics.className = "b05-drainage__basin-metrics"; + // 관경은 수식 미확정이라 백엔드가 null을 주며, 확정 전까지 "미정"으로 표기한다. + const pipe = + basin.pipe_diameter_mm === null ? "미정" : `Ø${Math.round(basin.pipe_diameter_mm)}mm`; + metrics.textContent = + `면적 ${formatArea(basin.area_m2)} · 표고 ${basin.relief_m.toFixed(1)}m · ` + + `유하 ${Math.round(basin.flow_length_m)}m · 관경 ${pipe}`; + row.title = `측점 누가거리 ${basin.chainage_m.toFixed(1)}m`; + row.append(badge, metrics); + row.addEventListener("click", () => { + selectedBasin = selectedBasin === basin.index ? null : basin.index; + renderBasinList(); + scheduleDraw(); + }); + basinList.append(row); + }); + } + + function formatArea(areaM2: number): string { + return areaM2 >= 10000 ? `${(areaM2 / 10000).toFixed(2)}ha` : `${Math.round(areaM2)}㎡`; + } + + /** 구조물 측점 후보 제안 + 유역 산정을 백엔드에 요청한다(계산은 전부 백엔드). + * 편집된 배관이 있으면 그 누가거리로 확정 산정하고, auto=true면 자동 제안으로 되돌린다. */ + async function analyze(auto = false): Promise { + if (!projectId) return; + analyzeButton.disabled = true; + status.hidden = false; + status.textContent = "세부유역을 산정하는 중…"; + showProgress(null, "세부유역을 산정하는 중…"); + try { + const chainages = !auto && pipeEditor.pipes().length > 0 ? pipeEditor.chainages() : undefined; + const response = await fetchDrainageBasins(projectId, chainages); + basins = response.basins; + mainBoundary = response.main_polygon_lonlat ?? []; + // 외곽선은 편집 핸들 간격으로 다시 찍힌 값이며, 저장된 편집분은 이미 반영돼 있다. + boundaryEditor.setBoundary(mainBoundary, response.boundary_overrides ?? []); + upstreamLines = (response.upstream_lonlat ?? []) as Array>; + flowArrows = (response.flow_arrows ?? []) as FlowArrow[]; + arrowSpacingM = response.arrow_spacing_m ?? 0; + // 계획도로선·2차 유역 외곽선은 B04 산출물을 그대로 받는다 — 여기서 다시 계산하지 않는다. + // 실제 사용된 배관 목록으로 마커를 동기화한다(유역 없는 관 포함). + pipeEditor.setPipes( + (response.pipes ?? []).map((pipe) => ({ + chainage_m: pipe.chainage_m, + reason: pipe.reason, + })), + ); + selectedBasin = null; + renderBasinList(); + syncPipeSelection(); + status.hidden = basins.length > 0; + if (basins.length === 0) status.textContent = "산정된 배수유역이 없습니다."; + scheduleDraw(); + } catch (error) { + status.hidden = false; + status.textContent = error instanceof Error ? error.message : "세부유역 산정에 실패했습니다."; + } finally { + analyzeButton.disabled = false; + showProgress(null, null); + } + } + + analyzeButton.addEventListener("click", () => void analyze()); + editButton.addEventListener("click", () => { + editMode = !editMode; + editButton.classList.toggle("is-active", editMode); + editButton.setAttribute("aria-pressed", String(editMode)); + syncPipeSelection(); + }); + deleteButton.addEventListener("click", () => void pipeEditor.deleteSelected()); + autoButton.addEventListener("click", () => { + pipeEditor.setPipes([]); + void analyze(true); + }); + boundaryButton.addEventListener("click", () => { + boundaryMode = !boundaryMode; + boundaryButton.classList.toggle("is-active", boundaryMode); + boundaryButton.setAttribute("aria-pressed", String(boundaryMode)); + boundaryEditor.setEditMode(boundaryMode); + }); + + /** 노선 전체가 보이도록 배율·중심을 맞춘다(초기 보기 = 도로 기준 줌인). + * + * B05는 노선 주변 배수유역을 보는 화면이라 도로에 맞춰 확대한 상태로 연다 + * (2026-08-01 사용자 지시). 배경 전체를 보려면 휠로 축소하면 된다. + * 노선이 없으면 배경 전체를 그대로 보여준다. */ + function fitToRoute(): void { + scale = 1; + offsetX = 0; + offsetY = 0; + if (!meta || routePoints.length < 2) return; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + routePoints.forEach((point) => { + if (point.x < minX) minX = point.x; + if (point.x > maxX) maxX = point.x; + if (point.y < minY) minY = point.y; + if (point.y > maxY) maxY = point.y; + }); + const rect = viewport.getBoundingClientRect(); + const view = computeRouteView( + meta, + { x_min: minX, x_max: maxX, y_min: minY, y_max: maxY }, + Math.max(rect.width, 1), + Math.max(rect.height, 1), + ); + scale = view.scale; + offsetX = view.offsetX; + offsetY = view.offsetY; + } + + async function loadLayers(): Promise { + if (!projectId) return; + const activeProjectId = projectId; + const sequence = ++loadSequence; + meta = null; + preparedLayers.clear(); + backgroundImage.removeAttribute("src"); + status.hidden = false; + status.textContent = "배경도를 불러오는 중…"; + showProgress(0, "배경도를 불러오는 중…"); + try { + const nextMeta = await fetchVWorldMeta(activeProjectId, "satellite"); + showProgress(1 / 3, "도엽 레이어를 불러오는 중…"); + const loaded = await Promise.all( + DRAINAGE_LAYERS.map(async (layer) => { + try { + // 도엽 레이어는 표시용 사본이라 보관함에 담아 두고 새로고침 때 그대로 쓴다. + const data = await fetchCachedSheetLayer(activeProjectId, layer); + return [layer, data] as const; + } catch { + return [layer, null] as const; + } + }), + ); + if (sequence !== loadSequence) return; + meta = nextMeta; + normalizer = createNormalizer(nextMeta); + let featureCount = 0; + loaded.forEach(([layer, data]) => { + if (!data) return; + featureCount += data.features?.length ?? 0; + preparedLayers.set(layer, prepareLayer(data, normalizer!)); + }); + // 주소에 시각을 붙이면 브라우저가 매번 다시 받는다. 서버가 ETag를 주므로 그대로 쓴다. + backgroundImage.src = getVWorldMapUrl(activeProjectId, "satellite"); + if (routePoints.length > 1) routeLayer = prepareMetricPolyline(routePoints, nextMeta); + pipeEditor.setContext(nextMeta, routePoints); + status.hidden = featureCount > 0; + if (featureCount === 0) status.textContent = "도엽 레이어가 없습니다. B04에서 임포트하세요."; + fitToRoute(); + scheduleDraw(); + showProgress(2 / 3, "세부유역을 산정하는 중…"); + // B04 분석 결과를 읽어 오는 것뿐이라 즉시 끝난다 — 페이지에 들어오면 바로 보여 준다. + void analyze(true); + } catch (error) { + if (sequence !== loadSequence) return; + status.hidden = false; + status.textContent = error instanceof Error ? error.message : "배경도를 불러오지 못했습니다."; + showProgress(null, null); + } + } + + viewport.addEventListener( + "wheel", + (event) => { + event.preventDefault(); + const prevScale = scale; + scale = Math.min(16, Math.max(0.5, scale * (event.deltaY < 0 ? 1.15 : 0.87))); + // 커서 아래 지점을 고정한 채 확대/축소 (B04 지도와 동일 동작). + const ratio = scale / prevScale; + const rect = viewport.getBoundingClientRect(); + const cursorX = event.clientX - rect.left - rect.width / 2; + const cursorY = event.clientY - rect.top - rect.height / 2; + offsetX = cursorX * (1 - ratio) + offsetX * ratio; + offsetY = cursorY * (1 - ratio) + offsetY * ratio; + scheduleDraw(); + }, + { passive: false }, + ); + viewport.addEventListener("pointerdown", (event) => { + // 중간 버튼 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹치지 않게 막는다. + if (event.button === 1) event.preventDefault(); + const rect = viewport.getBoundingClientRect(); + // 유역선 편집 중이면 외곽선 핸들을 먼저 본다 — 잡았으면 지도 팬을 시작하지 않는다. + if ( + normalizer && + boundaryEditor.handleDown( + normalizer, + currentView(), + event.clientX - rect.left, + event.clientY - rect.top, + ) + ) { + viewport.setPointerCapture(event.pointerId); + return; + } + // 배관 마커 클릭/추가가 처리되면 지도 팬은 시작하지 않는다. + if ( + pipeEditor.handleDown( + currentView(), + event.clientX - rect.left, + event.clientY - rect.top, + editMode, + ) + ) { + viewport.setPointerCapture(event.pointerId); + return; + } + dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY }; + viewport.setPointerCapture(event.pointerId); + }); + viewport.addEventListener("pointermove", (event) => { + const rect = viewport.getBoundingClientRect(); + // 외곽선 핸들을 끌고 있으면 그것만 처리한다. + if ( + normalizer && + boundaryEditor.handleMove( + normalizer, + currentView(), + event.clientX - rect.left, + event.clientY - rect.top, + ) + ) + return; + // 배관 드래그 중이면 마커 이동(계획선 스냅)만 처리한다. + if (pipeEditor.handleMove(currentView(), event.clientX - rect.left, event.clientY - rect.top)) + return; + if (!dragStart) return; + offsetX = dragStart.offsetX + event.clientX - dragStart.x; + offsetY = dragStart.offsetY + event.clientY - dragStart.y; + scheduleDraw(); + }); + const stopDragging = (): void => { + boundaryEditor.handleUp(); + pipeEditor.handleUp(); + dragStart = null; + }; + viewport.addEventListener("pointerup", stopDragging); + viewport.addEventListener("pointercancel", stopDragging); + + const resizeObserver = new ResizeObserver(scheduleDraw); + resizeObserver.observe(viewport); + + function setCollapsed(collapsed: boolean): void { + root.classList.toggle("is-collapsed", collapsed); + panelHandle.setOpen(!collapsed); + sessionStorage.setItem(COLLAPSED_KEY, String(collapsed)); + if (!collapsed) scheduleDraw(); + } + panelHandle.root.addEventListener("click", () => + setCollapsed(!root.classList.contains("is-collapsed")), + ); + setCollapsed(sessionStorage.getItem(COLLAPSED_KEY) !== "false"); + + return { + root, + load(nextProjectId: string) { + if (projectId === nextProjectId && meta) return; + projectId = nextProjectId; + void loadLayers(); + }, + setRoute(points) { + routePoints = points; + routeLayer = meta && points.length > 1 ? prepareMetricPolyline(points, meta) : null; + pipeEditor.setContext(meta, points); + if (routeLayer) fitToRoute(); + scheduleDraw(); + }, + hasBoundaryEdits: boundaryEditor.isDirty, + boundaryOverrides: boundaryEditor.overrides, + markBoundarySaved: boundaryEditor.markSaved, + dispose() { + loadSequence += 1; + if (frameHandle) { + window.cancelAnimationFrame(frameHandle); + frameHandle = 0; + } + resizeObserver.disconnect(); + }, + }; +} diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts new file mode 100644 index 00000000..a7ab7101 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts @@ -0,0 +1,246 @@ +import type { VWorldMeta } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; +import type { ViewState } from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender"; + +// 배관(관 매설) 지점 편집기 — 배수유역 패널의 계획선 위 마커 표시·추가·이동·삭제. +// 마커 위치의 단일 소스는 누가거리(chainage)다. 화면 좌표는 매 프레임 노선 +// 폴리라인(사업지 좌표계 m)을 따라 보간해 구하므로 확대/이동과 무관하게 정확하다. + +/** 배관 지점 1개. reason: stream(세류 교차)/spacing(300m 보충)/confirmed(사용자 확정). */ +export interface PipePoint { + chainage_m: number; + reason: string; +} + +interface RoutePointLike { + x: number; + y: number; + chainage_m?: number; +} + +/** 마커 히트 판정 반경(px)과 계획선 추가 클릭 허용 거리(px). */ +const HIT_RADIUS_PX = 12; +const ADD_SNAP_PX = 14; + +export interface PipeEditor { + setContext(meta: VWorldMeta | null, points: ReadonlyArray): void; + setPipes(pipes: ReadonlyArray): void; + pipes(): ReadonlyArray; + chainages(): number[]; + selected(): number | null; + select(index: number | null): void; + deleteSelected(): boolean; + /** 편집 상호작용. 처리했으면 true(패널은 지도 팬을 생략한다). */ + handleDown(view: ViewState, screenX: number, screenY: number, editMode: boolean): boolean; + handleMove(view: ViewState, screenX: number, screenY: number): boolean; + handleUp(): boolean; + draw( + context: CanvasRenderingContext2D, + view: ViewState, + colorOf: (chainage: number, position: number) => string, + ): void; +} + +export function createPipeEditor(onChange: () => void): PipeEditor { + let meta: VWorldMeta | null = null; + let route: Array<{ x: number; y: number; chainage: number }> = []; + let totalChainage = 0; + let pipeList: PipePoint[] = []; + let selectedIndex: number | null = null; + let draggingIndex: number | null = null; + let dragMoved = false; + + /** 화면 → 사업지 좌표계 m (MapRender affine의 역변환). */ + function screenToMetric( + view: ViewState, + sx: number, + sy: number, + ): { x: number; y: number } | null { + if (!meta) return null; + 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; + if (!ax || !ay) return null; + const nx = (sx - bx) / ax; + const ny = (sy - by) / ay; + return { + x: meta.x_min + nx * (meta.width_meters || 1), + y: meta.y_min + (1 - ny) * (meta.height_meters || 1), + }; + } + + function metricToScreen(view: ViewState, x: number, y: number): { x: number; y: number } | null { + if (!meta) return null; + const nx = (x - meta.x_min) / (meta.width_meters || 1); + const ny = 1 - (y - meta.y_min) / (meta.height_meters || 1); + 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; + return { x: nx * ax + bx, y: ny * ay + by }; + } + + /** 1m가 화면에서 몇 px인지 (거리 판정용). */ + function pxPerMeter(view: ViewState): number { + if (!meta) return 1; + return (view.mapRect.width * view.scale) / (meta.width_meters || 1); + } + + function chainageToXY(chainage: number): { x: number; y: number } | null { + if (route.length < 2) return null; + if (chainage <= route[0].chainage) return { x: route[0].x, y: route[0].y }; + for (let i = 1; i < route.length; i += 1) { + const prev = route[i - 1]; + const next = route[i]; + if (chainage > next.chainage) continue; + const span = next.chainage - prev.chainage || 1; + const t = (chainage - prev.chainage) / span; + return { x: prev.x + (next.x - prev.x) * t, y: prev.y + (next.y - prev.y) * t }; + } + const last = route[route.length - 1]; + return { x: last.x, y: last.y }; + } + + /** 사업지 좌표에서 노선 최근접 지점의 누가거리와 이탈 거리(m). */ + function nearestChainage(x: number, y: number): { chainage: number; distance: number } | null { + if (route.length < 2) return null; + let best: { chainage: number; distance: number } | null = null; + for (let i = 1; i < route.length; i += 1) { + const a = route[i - 1]; + const b = route[i]; + const dx = b.x - a.x; + const dy = b.y - a.y; + const lengthSq = dx * dx + dy * dy || 1; + const t = Math.max(0, Math.min(1, ((x - a.x) * dx + (y - a.y) * dy) / lengthSq)); + const px = a.x + dx * t; + const py = a.y + dy * t; + const distance = Math.hypot(x - px, y - py); + const chainage = a.chainage + (b.chainage - a.chainage) * t; + if (!best || distance < best.distance) best = { chainage, distance }; + } + return best; + } + + function sortPipes(): void { + const selected = selectedIndex === null ? null : pipeList[selectedIndex]; + pipeList.sort((a, b) => a.chainage_m - b.chainage_m); + selectedIndex = selected === null ? null : pipeList.indexOf(selected); + } + + return { + setContext(nextMeta, points) { + meta = nextMeta; + let cumulative = 0; + route = points.map((point, index) => { + if (index > 0) { + const prev = points[index - 1]; + cumulative += Math.hypot(point.x - prev.x, point.y - prev.y); + } + return { x: point.x, y: point.y, chainage: point.chainage_m ?? cumulative }; + }); + totalChainage = route.length > 0 ? route[route.length - 1].chainage : 0; + }, + setPipes(pipes) { + pipeList = pipes.map((pipe) => ({ ...pipe })); + sortPipes(); + selectedIndex = null; + draggingIndex = null; + }, + pipes: () => pipeList, + chainages: () => pipeList.map((pipe) => Math.round(pipe.chainage_m * 100) / 100), + selected: () => selectedIndex, + select(index) { + selectedIndex = index; + }, + deleteSelected() { + if (selectedIndex === null) return false; + pipeList.splice(selectedIndex, 1); + selectedIndex = null; + onChange(); + return true; + }, + handleDown(view, screenX, screenY, editMode) { + dragMoved = false; + // 마커 클릭: 선택 (편집 모드 여부 무관), 편집 모드면 드래그 시작. + for (let i = pipeList.length - 1; i >= 0; i -= 1) { + const xy = chainageToXY(pipeList[i].chainage_m); + if (!xy) continue; + const screen = metricToScreen(view, xy.x, xy.y); + if (!screen) continue; + if (Math.hypot(screenX - screen.x, screenY - screen.y) <= HIT_RADIUS_PX) { + selectedIndex = i; + if (editMode) draggingIndex = i; + onChange(); + return true; + } + } + if (!editMode) return false; + // 계획선 클릭: 그 지점에 배관 추가. + const metric = screenToMetric(view, screenX, screenY); + if (!metric) return false; + const nearest = nearestChainage(metric.x, metric.y); + if (!nearest || nearest.distance * pxPerMeter(view) > ADD_SNAP_PX) return false; + pipeList.push({ chainage_m: nearest.chainage, reason: "confirmed" }); + sortPipes(); + selectedIndex = pipeList.findIndex( + (pipe) => Math.abs(pipe.chainage_m - nearest.chainage) < 1e-6, + ); + draggingIndex = selectedIndex; + onChange(); + return true; + }, + handleMove(view, screenX, screenY) { + if (draggingIndex === null) return false; + const metric = screenToMetric(view, screenX, screenY); + if (!metric) return true; + const nearest = nearestChainage(metric.x, metric.y); + if (!nearest) return true; + const clamped = Math.max(0, Math.min(totalChainage, nearest.chainage)); + pipeList[draggingIndex].chainage_m = clamped; + pipeList[draggingIndex].reason = "confirmed"; + dragMoved = true; + onChange(); + return true; + }, + handleUp() { + if (draggingIndex === null) return false; + draggingIndex = null; + if (dragMoved) { + sortPipes(); + onChange(); + } + return true; + }, + draw(context, view, colorOf) { + pipeList.forEach((pipe, position) => { + const xy = chainageToXY(pipe.chainage_m); + if (!xy) return; + const screen = metricToScreen(view, xy.x, xy.y); + if (!screen) return; + const isSelected = position === selectedIndex; + const radius = isSelected ? 9 : 7; + context.beginPath(); + context.arc(screen.x, screen.y, radius, 0, Math.PI * 2); + context.fillStyle = colorOf(pipe.chainage_m, position); + context.fill(); + context.lineWidth = isSelected ? 2.5 : 1.5; + context.strokeStyle = isSelected ? "#111827" : "#374151"; + context.stroke(); + context.fillStyle = "#111827"; + context.font = "bold 10px sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText(String(position + 1), screen.x, screen.y); + // 누가거리 라벨 — 마커 우상단. + context.font = "10px sans-serif"; + context.textAlign = "left"; + context.fillStyle = "#1f2937"; + context.fillText( + `${pipe.chainage_m.toFixed(0)}m`, + screen.x + radius + 3, + screen.y - radius, + ); + }); + }, + }; +} diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index 61b2d44c..54d429a0 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -1,5 +1,7 @@ import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; +import { purgeOtherProjects } from "../A00_Common/b_asset_cache"; +import { createProgressCircle } from "@ui/ui_template_progress"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { @@ -8,13 +10,15 @@ import { WORKFLOW_STEP_ROUTES, } from "../A00_Common/b_workflow_nav"; import { - fetchSurfacePointCloud, + fetchConfirmedSurface, listSurfaceModels, type SurfaceModelSummary, } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; import { confirmRoute, fetchLatestRoute, + routeLatestCacheKey, + saveDrainageBoundary, solveRoute, updateContourInterval, type CirclePoint, @@ -167,6 +171,8 @@ export async function renderB05Route(root: HTMLElement): Promise { return; } const activeProjectId: string = projectId; + // 새로고침으로 바로 들어온 경우에도 다른 프로젝트 자료는 보관함에서 지운다. + void purgeOtherProjects(activeProjectId); const viewer = createRouteViewer(); const profilePanel = createRouteProfilePanel( @@ -241,7 +247,7 @@ export async function renderB05Route(root: HTMLElement): Promise { * 확정 이력이 있으면 매 진입마다 DB(latest) 조회 대신 브라우저 세션 캐시를 * 우선 사용해 응답속도를 높인다. 캐시 미스면 latest를 조회해 적재하고, * solve·확정 성공 시 신선한 값으로 갱신한다(세션 = 탭 단위, 탭 종료 시 소멸). */ - const latestCacheKey = `b05:latest:${activeProjectId}`; + const latestCacheKey = routeLatestCacheKey(activeProjectId); function readLatestCache(): RouteLatestResponse | null { try { @@ -407,15 +413,18 @@ export async function renderB05Route(root: HTMLElement): Promise { async function restoreSections(routeId: number): Promise { let detail: SectionDetailResponse; + profilePanel.setLoading("종단면 자료를 불러오는 중…"); try { detail = await fetchSectionDetail(activeProjectId, routeId); } catch { // 종횡단 데이터 자체가 없는 경우(생성 실패·최초 진입)는 빈 안내로 둔다. currentSectionDetail = null; + profilePanel.setLoading(null); profilePanel.clear(); viewer.renderStationLines([], 0); return; } + profilePanel.setLoading(null); try { renderSections(detail, routeId); // 복귀/최초 진입 시(클라이언트 목록이 비어 있을 때만) 확정된 비정규 측점을 사이드바에 복원한다. @@ -447,6 +456,7 @@ export async function renderB05Route(root: HTMLElement): Promise { routeReady = Boolean(next.route && next.route_points.length > 1); stale = false; panel.setStale(false); + profilePanel.setRoutePolyline(next.route_points ?? []); if (next.route) { const stored = next.route.algorithm_params ?? {}; viewer.markers.renderRoute( @@ -539,8 +549,32 @@ export async function renderB05Route(root: HTMLElement): Promise { } } + /** 유역선을 손으로 고쳤으면 확정 전에 저장 여부를 묻는다. 승인해야만 옮긴 포인트를 남긴다. */ + async function saveBoundaryEditsIfWanted(): Promise { + const drainage = profilePanel.drainage; + if (!drainage.hasBoundaryEdits()) return; + const overrides = drainage.boundaryOverrides(); + const accepted = window.confirm( + `배수유역 외곽선에서 옮긴 포인트 ${overrides.length}개를 저장할까요?\n` + + "저장하면 노선이 바뀌어 유역을 다시 계산해도 옮긴 자리가 유지됩니다.", + ); + if (!accepted) return; + try { + await saveDrainageBoundary(activeProjectId, overrides); + drainage.markBoundarySaved(); + showToast("배수유역 외곽선 편집을 저장했습니다.", "success"); + } catch (error) { + // 유역선 저장 실패가 경로 확정 자체를 막지는 않는다. + showToast( + error instanceof Error ? error.message : "배수유역 외곽선 저장에 실패했습니다.", + "error", + ); + } + } + async function confirm(): Promise { if (!routeReady || stale) return; + await saveBoundaryEditsIfWanted(); showLoadingOverlay(); try { // 종단 계획선 편집은 화면에서만 계산해 두었으므로 확정 직전에 영속화한다. @@ -571,43 +605,24 @@ export async function renderB05Route(root: HTMLElement): Promise { } } - const [workflowState, models, latestResponse, sectionContext, configuredRoadWidths] = - await Promise.all([ - fetchWorkflowState(activeProjectId), - listSurfaceModels(activeProjectId), - // 세션 캐시 우선(응답속도) — 최초 진입/캐시 미스 시에만 DB(latest)를 읽는다. - loadLatest(), - fetchSectionContext(activeProjectId), - fetchRoadWidths(activeProjectId), - ]); - roadWidths = configuredRoadWidths; - confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null; - if (!confirmedSurface) { - showToast("확정된 지표면 모델이 없습니다.", "error"); - } else { - const cloud = await fetchSurfacePointCloud( - activeProjectId, - latestResponse.surface_params.source_filter, - ); - panel.restore({ - stationInterval: sectionContext.defaults.station_interval_m, - crossSampleInterval: sectionContext.defaults.cross_sample_interval_m, - longSampleInterval: sectionContext.defaults.long_sample_interval_m, - }); - restorePanel(latestResponse); - await viewer.loadSurface( - activeProjectId, - confirmedSurface.id, - latestResponse.surface_params.method, - latestResponse.surface_params.smooth, - latestResponse.surface_params.contour_interval_m, - toBounds(cloud.bounds), - ); - renderLatest(latestResponse); - if (latestResponse.route) await restoreSections(latestResponse.route.id); + /* ── 진입 로딩 ───────────────────────────────────────────────────────── + * 전부 받아 놓고 한 번에 그리면 몇 초 동안 빈 화면만 보인다. 화면 틀을 먼저 띄우고 + * 자료가 끝나는 순서대로 채운다. 3D 지형이 가장 느리므로 맨 마지막에 올리고, 그동안 + * 3D 뷰포트에 공통 프로그레스 서클을 띄운다(2026-08-01 사용자 지시). */ + const LOAD_STEP_COUNT = 5; + // 3D 뷰포트 정중앙. 하단 종단 패널(z-index 3)보다 아래라 패널에 가려지는 것은 무방하다 + // (2026-08-01 사용자 지시). + const progress = createProgressCircle({ label: "화면 틀을 준비하는 중…", overlay: true }); + viewer.root.append(progress.root); + let loadedSteps = 0; + function advanceLoading(label: string): void { + loadedSteps += 1; + progress.set(Math.min(1, loadedSteps / LOAD_STEP_COUNT), label); } - latest = latestResponse; - restoring = false; + + // ① 워크플로우 상태 — 화면 틀(레이아웃·단계바)을 세우는 데 필요한 최소 자료. + const workflowState = await fetchWorkflowState(activeProjectId); + advanceLoading("노선 정보를 불러오는 중…"); const mainContent = document.createElement("div"); mainContent.className = "b05-route__main"; @@ -625,4 +640,59 @@ export async function renderB05Route(root: HTMLElement): Promise { }); layout.root.classList.add("b05-route-layout"); root.replaceChildren(layout.root); + + try { + // ② 좌측 폼·노선 설정값 — 도착하는 대로 폼과 3D 마커 복원에 쓴다. + const [latestResponse, sectionContext, configuredRoadWidths] = await Promise.all([ + // 세션 캐시 우선(응답속도) — 최초 진입/캐시 미스 시에만 DB(latest)를 읽는다. + loadLatest(), + fetchSectionContext(activeProjectId), + fetchRoadWidths(activeProjectId), + ]); + roadWidths = configuredRoadWidths; + panel.restore({ + stationInterval: sectionContext.defaults.station_interval_m, + crossSampleInterval: sectionContext.defaults.cross_sample_interval_m, + longSampleInterval: sectionContext.defaults.long_sample_interval_m, + }); + restorePanel(latestResponse); + renderLatest(latestResponse); + latest = latestResponse; + advanceLoading("확정 지표면 모델을 확인하는 중…"); + + // ③ 확정 지표면 모델 목록. + const models = await listSurfaceModels(activeProjectId); + confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null; + advanceLoading("종단면 자료를 불러오는 중…"); + + // ④ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다. + if (latestResponse.route) await restoreSections(latestResponse.route.id); + advanceLoading("3D 지형을 불러오는 중…"); + + // ⑤ 3D 지형 — 가장 무거우므로 맨 마지막. + if (!confirmedSurface) { + showToast("확정된 지표면 모델이 없습니다.", "error"); + } else { + // 지형 가장자리만 필요하다 — 포인트클라우드 전체(수십 MB)는 받지 않는다. + const confirmed = await fetchConfirmedSurface(activeProjectId); + if (!confirmed.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다."); + await viewer.loadSurface( + activeProjectId, + confirmedSurface.id, + latestResponse.surface_params.method, + latestResponse.surface_params.smooth, + latestResponse.surface_params.contour_interval_m, + toBounds(confirmed.bounds), + ); + // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다. + renderLatest(latestResponse); + if (currentSectionDetail) renderStationLines(currentSectionDetail); + } + advanceLoading(""); + } catch (error) { + showToast(error instanceof Error ? error.message : "화면을 불러오지 못했습니다.", "error"); + } finally { + progress.remove(); + restoring = false; + } } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index 2202d33d..9b9cece8 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -21,6 +21,8 @@ import { } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal"; import { LONG_PAD } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; +import { createDrainagePanel } from "./B05_wf2_Route_UI_Drainage_Panel"; +import { createProgressCircle } from "@ui/ui_template_progress"; import { showToast } from "@ui/ui_template_elements"; import { saveProfileAlignment } from "./B05_wf2_Route_Api_Fetch"; import type { @@ -50,6 +52,9 @@ import { } from "./B05_wf2_Route_UI_IrregularStations"; import type { SectionStation } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch"; import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css"; +// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때 +// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인. +import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css"; const COLLAPSED_KEY = "b05-route-profile-collapsed"; /** 정보 라인을 뺀 본문 세로를 그래프 40% : 테이블 60%로 나눈다(4:6, 6이 테이블). */ @@ -266,7 +271,21 @@ export function createRouteProfilePanel( empty.className = "b05-route-profile__empty"; empty.textContent = "최적 경로를 계산하면 종단면도가 표시됩니다."; body.append(empty); - root.append(panelHandle.root, balanceBar, body); + // 종단면 본문 + 우측 배수유역 패널을 나란히 놓는 2단 구성. + // 배수유역 패널이 이 안에 있으므로 하단 패널을 접으면 함께 사라진다(사용자 지시). + // 그래프 영역 정중앙 로딩 서클 — 종단면 자료가 도착할 때까지 빈 안내만 보인다. + // body는 그릴 때마다 자식이 통째로 교체되므로 서클은 감싸는 칸에 둔다. + const progress = createProgressCircle({ overlay: true }); + progress.root.hidden = true; + const bodyWrap = document.createElement("div"); + bodyWrap.className = "b05-route-profile__body-wrap"; + bodyWrap.append(body, progress.root); + const content = document.createElement("div"); + content.className = "b05-route-profile__content"; + const drainagePanel = createDrainagePanel(); + content.append(bodyWrap, drainagePanel.root); + root.append(panelHandle.root, balanceBar, content); + drainagePanel.load(projectId); let detail: SectionDetailResponse | null = null; let selectedStationId: string | null = null; @@ -580,6 +599,10 @@ export function createRouteProfilePanel( selectedStationId = stationId; draw(); }, + /** 확정된 노선 평면 선형을 우측 배수유역 지도에 겹친다(사업지 좌표계 m). */ + setRoutePolyline(points: ReadonlyArray<{ x: number; y: number }>) { + drainagePanel.setRoute(points); + }, /** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */ setIrregularStations(stations: IrregularStation[]) { irregularStations = stations; @@ -622,6 +645,13 @@ export function createRouteProfilePanel( balanceBar.replaceChildren(); body.replaceChildren(empty); }, + /** 배수유역도 패널 — 경로 확정 흐름에서 유역선 편집 저장 여부를 묻는 데 쓴다. */ + drainage: drainagePanel, + /** 그래프 영역 로딩 서클. 문구를 주면 켜고 null이면 끈다. */ + setLoading(label: string | null) { + progress.root.hidden = label === null; + if (label !== null) progress.set(null, label); + }, dispose() { window.clearTimeout(resizeTimer); resizeObserver.disconnect(); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts index 8da2bf6d..adbdfad8 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts @@ -374,6 +374,7 @@ function buildSelectedColumn( interval: number, display: { station: number; cumulative: number }, onAdjustStation?: (chainageM: number, deltaM: number) => void, + isIrregular = false, ): HTMLElement { const plan = interpolateSample(alignment.samples, chainage, "elevation_m"); const ground = interpolateSample(alignment.samples, chainage, "ground_elevation_m"); @@ -419,7 +420,10 @@ function buildSelectedColumn( { value: { text: curve ? curve.l_m.toFixed(2) : "" }, modifier: "" }, // 곡선 L { value: { text: curve ? curve.r_m.toFixed(1) : "" }, modifier: "" }, // 곡선 R ]; - const column = element("div", "b05-profile-table__irregular-col"); + const column = element( + "div", + `b05-profile-table__irregular-col${isIrregular ? " is-floating" : ""}`, + ); column.style.left = `${centerX}px`; column.style.width = `${cellWidth}px`; rows.forEach((row) => { @@ -501,15 +505,20 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement { ? alignment.stations.findIndex((row) => row.station_id === options.selectedStationId) : -1; if (selectedIrregular) { + const centerX = x(selectedIrregular.chainage_m); + // 비정규 측점은 측점 격자와 무관한 위치라 오버레이가 좌우 이웃 셀을 반씩 덮어 값이 잘려 + // 보인다. 겹치는 규칙 측점 셀을 숨겨 하이라이트 창이 깨끗한 자리에 뜨게 한다. + hideCoveredCells(table, centers, centerX, cellWidth); table.append( buildSelectedColumn( selectedIrregular.chainage_m, alignment, - x(selectedIrregular.chainage_m), + centerX, cellWidth, stationInterval, display, options.onAdjustStation, + true, ), ); } else if (selectedRegularIndex >= 0) { @@ -522,8 +531,33 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement { stationInterval, display, options.onAdjustStation, + false, ), ); } return table; } + +/** + * 하이라이트 열이 덮는 자리의 규칙 측점 값 셀을 숨긴다. + * 셀과 하이라이트 열 모두 폭 `cellWidth`를 중심 정렬로 쓰므로, 중심 간 거리가 `cellWidth` + * 미만이면 두 사각형이 겹친다. 겹치는 측점은 어차피 값이 잘려 읽을 수 없으므로 통째로 숨긴다. + */ +function hideCoveredCells( + table: HTMLElement, + centers: readonly number[], + columnCenter: number, + cellWidth: number, +): void { + const covered = new Set(); + centers.forEach((center, index) => { + if (Math.abs(center - columnCenter) < cellWidth) covered.add(index); + }); + if (covered.size === 0) return; + table.querySelectorAll(".b05-profile-table__row").forEach((row) => { + const cells = row.querySelectorAll( + ".b05-profile-table__cell, .b05-profile-table__curve", + ); + covered.forEach((index) => cells[index]?.classList.add("is-covered")); + }); +} diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index b9cd8f16..d09c8761 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -16,6 +16,7 @@ } .b05-route__main { + position: relative; display: flex; flex-direction: column; width: 100%; @@ -35,28 +36,52 @@ } /* 하단 종단 패널: 그래프 + 12행 도면 테이블을 담기 위해 화면 높이의 60%를 차지한다. + 3D 뷰포트를 밀어내지 않고 그 위를 덮는 오버레이로 띄운다 — 패널을 여닫아도 3D 뷰 + 크기가 변하지 않아 카메라 시점과 렌더 비용이 그대로 유지된다. 접기 핸들로 언제든 내릴 수 있어 3D 뷰를 전체 영역으로 볼 수 있다. */ .b05-route-profile { - position: relative; + position: absolute; + z-index: 3; + right: 0; + bottom: 0; + left: 0; display: flex; - flex: 0 0 60vh; - flex: 0 0 60dvh; + height: 60vh; + height: 60dvh; flex-direction: column; min-height: 0; overflow: visible; border-top: 1px solid var(--color-border); background: var(--color-surface-raised); - transition: flex-basis var(--transition-fast); + transition: height var(--transition-fast); } .b05-route-profile.is-collapsed { - flex-basis: 0; + height: 0; + min-height: 0; +} + +/* 종단면 본문 + 우측 배수유역 패널의 2단 가로 배치. */ +.b05-route-profile__content { + display: flex; + flex: 1 1 auto; + min-height: 0; + min-width: 0; +} + +/* 그래프 본문 + 로딩 서클을 겹치기 위한 칸. body는 그릴 때마다 자식이 교체된다. */ +.b05-route-profile__body-wrap { + position: relative; + display: flex; + flex: 1 1 auto; + min-width: 0; min-height: 0; } .b05-route-profile__body { box-sizing: border-box; flex: 1 1 auto; + min-width: 0; min-height: 0; overflow-x: scroll; overflow-y: hidden; @@ -110,6 +135,7 @@ transform: translateY(-50%); } +.b05-route-profile.is-collapsed .b05-route-profile__body-wrap, .b05-route-profile.is-collapsed .b05-route-profile__body, .b05-route-profile.is-collapsed .b05-route-profile__balance { display: none; @@ -151,23 +177,27 @@ height: 100%; } +/* 3D 뷰포트 안내 문구 — 우상단 텍스트만(배경·테두리 없음, 회색). 2026-08-01 사용자 지시. + 우측 세로 진행단계 오버레이와 겹치지 않도록 그 폭만큼 안쪽으로 들여 놓는다. */ .b05-route__viewer-status { position: absolute; - inset: var(--spacing-16) auto auto var(--spacing-16); + z-index: 2; + top: var(--spacing-16); + right: calc(var(--wf-left-panel-width) / 2 + var(--spacing-48)); + left: auto; max-width: 420px; - padding: var(--spacing-8) var(--spacing-16); - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - background: var(--color-surface-raised); - color: var(--color-text-body); + color: var(--color-text-muted); font-size: var(--text-caption); + text-align: right; pointer-events: none; } +/* 뷰셋·표시 토글 버튼 묶음 — 좌상단(2026-08-01 사용자 지시). + 안내 문구가 우상단으로 옮겨져 좌상단이 비었다. */ .b05-route__view-controls { position: absolute; z-index: 2; - top: 58px; + top: var(--spacing-16); left: var(--spacing-16); display: flex; align-items: center; @@ -663,6 +693,22 @@ pointer-events: none; } +/* 비정규(구조물) 측점은 측점 격자와 무관한 위치에 떠서, 이웃 셀을 가린 자리에 뜬다. + 떠 있는 창임이 드러나게 그림자·굵은 테두리로 구분한다(규칙 측점은 격자와 일치해 불필요). */ +.b05-profile-table__irregular-col.is-floating { + border-inline-width: 2px; + box-shadow: + 0 0 0 1px var(--color-surface-raised), + 0 4px 12px rgb(0 0 0 / 28%); +} + +/* 하이라이트 창이 덮은 자리의 규칙 측점 값 셀 — 값이 잘려 읽을 수 없으므로 숨긴다. + 자리(레이아웃)는 유지해야 세로 구분선 격자가 끊기지 않으므로 visibility로 감춘다. */ +.b05-profile-table__cell.is-covered, +.b05-profile-table__curve.is-covered { + visibility: hidden; +} + .b05-profile-table__irregular-col-cell { display: flex; flex: 1 1 0; @@ -784,3 +830,208 @@ color: color-mix(in srgb, var(--color-royal-amethyst, rgb(139 92 246)) 85%, var(--color-text)); opacity: 0.95; } + +/* ─── 배수유역도 패널 (하단 종단 패널 안쪽 우측 2단 사이드 패널) ──────────── */ +.b05-drainage { + position: relative; + display: flex; + width: 38%; + min-width: 320px; + max-width: 640px; + flex: 0 0 auto; + flex-direction: column; + min-height: 0; + border-left: 1px solid var(--color-border); + background: var(--color-surface-raised); + transition: width var(--transition-fast); +} + +/* 접으면 폭만 0으로 줄고, 좌측 가장자리 핸들은 남아 다시 펼 수 있다. */ +.b05-drainage.is-collapsed { + width: 0; + min-width: 0; +} + +.b05-drainage.is-collapsed .b05-drainage__header, +.b05-drainage.is-collapsed .b05-drainage__viewport { + display: none; +} + +/* 좌측 가장자리 세로 중앙 핸들 — 공용 side 핸들을 패널 왼쪽 밖으로 내보낸다. */ +.b05-drainage .ui-workflow-overlay__handle--side { + z-index: 2; + right: auto; + left: calc(-1 * var(--spacing-24)); + border-right: 0; + border-radius: var(--radius-buttons) 0 0 var(--radius-buttons); +} + +.b05-drainage__header { + display: flex; + flex: 0 0 auto; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-8) calc(var(--spacing-8) + var(--spacing-4)); + border-bottom: 1px solid var(--color-border); +} + +.b05-drainage__header h3 { + margin: 0; + color: var(--color-text); + font-size: var(--text-body-sm); +} + +.b05-drainage__layers { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4); +} + +/* 표시 토글 버튼 — B04 지도 레이어 버튼(.b04-map__layer-button--gis)과 같은 양식을 쓴다. + 크기(패딩·글자)만 이 패널 기준을 유지한다(2026-08-01 사용자 지시). */ +.b05-drainage__layer-button { + padding: 2px var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: var(--color-surface); + color: var(--color-text-secondary); + font-size: var(--text-caption); + opacity: 0.55; + cursor: pointer; +} + +/* 켜진 레이어는 그 레이어의 선 색을 테두리·글자·안쪽 링에 그대로 쓴다(지도와 바로 대조). */ +.b05-drainage__layer-button.is-active { + border-color: var(--b05-layer-color, var(--color-border)); + box-shadow: inset 0 0 0 1px var(--b05-layer-color, transparent); + color: var(--b05-layer-color, var(--color-text-body)); + opacity: 1; +} + +.b05-drainage__viewport { + position: relative; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + background: var(--color-surface); + cursor: grab; + touch-action: none; +} + +.b05-drainage__viewport:active { + cursor: grabbing; +} + +.b05-drainage__image, +.b05-drainage__canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.b05-drainage__image { + object-fit: contain; + transform-origin: center; + user-select: none; + pointer-events: none; +} + +.b05-drainage__canvas { + pointer-events: none; +} + +.b05-drainage__status { + position: absolute; + inset: var(--spacing-8) var(--spacing-8) auto var(--spacing-8); + color: var(--color-text-secondary); + font-size: var(--text-caption); + pointer-events: none; +} + +.b05-drainage__analyze { + margin-left: auto; + padding: 2px var(--spacing-8); + border: 1px solid + color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 55%, transparent); + border-radius: var(--radius-inputs); + background: var(--color-surface); + color: var(--color-text-body); + font-size: var(--text-caption); + cursor: pointer; +} + +.b05-drainage__analyze:disabled { + opacity: 0.45; + cursor: default; +} + +/* 배관 편집 도구 버튼 — "유역 산정" 우측에 나란히(auto 마진 해제). */ +.b05-drainage__tool { + margin-left: var(--spacing-4, 4px); +} + +/* 배관 편집 토글 활성 상태. */ +.b05-drainage__analyze.is-active { + background: color-mix( + in srgb, + var(--color-royal-amethyst, rgb(109 40 217)) 18%, + var(--color-surface) + ); + border-color: var(--color-royal-amethyst, rgb(109 40 217)); +} + +/* 유역 제원 목록 — 면적·유역표고·유하거리·관경(수식 확정 전까지 "미정"). */ +.b05-drainage__basins { + display: flex; + max-height: 34%; + flex: 0 0 auto; + flex-direction: column; + gap: 2px; + overflow-y: auto; + padding: var(--spacing-8); + border-top: 1px solid var(--color-border); +} + +.b05-drainage__basin { + display: flex; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid transparent; + border-radius: var(--radius-inputs); + background: none; + color: var(--color-text-body); + font-size: var(--text-caption); + text-align: left; + cursor: pointer; +} + +.b05-drainage__basin:hover, +.b05-drainage__basin.is-selected { + border-color: var(--color-border); + background: var(--color-surface); +} + +/* 지도 위 서클 번호와 같은 파스텔 색을 써서 목록 항목과 유역을 눈으로 잇는다. */ +.b05-drainage__basin-index { + display: inline-flex; + width: 20px; + height: 20px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border: 1px solid var(--color-border); + border-radius: 50%; + color: #1f2937; + font-size: 11px; + font-weight: 600; +} + +.b05-drainage__basin-metrics { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts b/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts index 5f3f5f47..f98ab03d 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts @@ -3,6 +3,8 @@ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js"; import { API_BASE_URL } from "@config/config_frontend"; +import { fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache"; +import { bindCursorPivotControls } from "../B04_wf1_Surface/B04_wf1_Surface_UI_Camera"; import { createRouteMarkers, sceneToModel, @@ -112,6 +114,16 @@ export function createRouteViewer(): RouteViewer { let draggingMarker = false; let lastDragPoint: { x: number; y: number; z: number } | null = null; const markers = createRouteMarkers(scene, () => bounds); + // 회전·줌 중심을 커서 아래 지형 지점으로 (B04 뷰어들과 공용 유틸). + // 마커를 잡고 있는 동안에는 회전을 넘겨 드래그 이동이 우선하게 한다. + const releaseCursorPivot = bindCursorPivotControls({ + camera, + controls, + element: canvas, + pickables: () => (terrain ? [terrain] : []), + blocked: () => dragCandidate !== null || draggingMarker || movingSelected, + scene, + }); function clearContours(): void { disposeObject(contours); @@ -151,35 +163,42 @@ export function createRouteViewer(): RouteViewer { async function reloadContours(interval: number): Promise { if (!current || !bounds) return; current.interval = interval; - const response = await fetch( - `${API_BASE_URL}/projects/${current.projectId}/surface/models/${current.modelId}/contour?interval=${interval}&smooth=${current.smooth}`, - { credentials: "include", cache: "no-store" }, - ); - if (!response.ok) throw new Error("등고선 조회에 실패했습니다."); - const data = (await response.json()) as { + // 등고선도 보관함에서 먼저 찾는다 — 같은 파일을 새로고침마다 다시 내려받지 않는다. + const data = await fetchCachedJson<{ contours: Array<{ level: number; coordinates: [number, number, number][] }>; - }; + }>( + current.projectId, + `${API_BASE_URL}/projects/${current.projectId}/surface/models/${current.modelId}/contour?interval=${interval}&smooth=${current.smooth}`, + ); clearContours(); + // 등고선 한 가닥마다 3D 객체를 만들면 수백 개가 된다. 주곡선·보조곡선 두 덩어리로 합친다. + const majorPoints: THREE.Vector3[] = []; + const minorPoints: THREE.Vector3[] = []; + const cx = (bounds.x[0] + bounds.x[1]) / 2; + const cy = (bounds.y[0] + bounds.y[1]) / 2; + const cz = (bounds.z[0] + bounds.z[1]) / 2; data.contours.forEach((contour) => { - const points = contour.coordinates.map(([x, y, z]) => { - const cx = (bounds!.x[0] + bounds!.x[1]) / 2; - const cy = (bounds!.y[0] + bounds!.y[1]) / 2; - const cz = (bounds!.z[0] + bounds!.z[1]) / 2; - return new THREE.Vector3(x - cx, z - cz + 0.15, -(y - cy)); - }); - if (points.length > 1) { - contours.add( - new THREE.Line( - new THREE.BufferGeometry().setFromPoints(points), - new THREE.LineBasicMaterial({ - color: contour.level % (interval * 5) === 0 ? 0xd97706 : 0xf59e0b, - transparent: true, - opacity: 0.75, - }), - ), - ); + const points = contour.coordinates.map( + ([x, y, z]) => new THREE.Vector3(x - cx, z - cz + 0.15, -(y - cy)), + ); + if (points.length < 2) return; + const bucket = contour.level % (interval * 5) === 0 ? majorPoints : minorPoints; + for (let index = 0; index < points.length - 1; index += 1) { + bucket.push(points[index], points[index + 1]); } }); + [ + { points: minorPoints, color: 0xf59e0b }, + { points: majorPoints, color: 0xd97706 }, + ].forEach(({ points, color }) => { + if (points.length === 0) return; + contours.add( + new THREE.LineSegments( + new THREE.BufferGeometry().setFromPoints(points), + new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.75 }), + ), + ); + }); } function terrainPoint( @@ -321,17 +340,14 @@ export function createRouteViewer(): RouteViewer { disposeObject(terrain); } const url = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/preview?smooth=${smooth}`; + // 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다). + const buffer = await fetchCachedBytes(projectId, url); terrain = await new Promise((resolve, reject) => { if (method === "meshfree") { - new PLYLoader().load( - url, - (geometry) => - resolve(new THREE.Points(geometry, new THREE.PointsMaterial({ size: 0.35 }))), - undefined, - reject, - ); + const geometry = new PLYLoader().parse(buffer); + resolve(new THREE.Points(geometry, new THREE.PointsMaterial({ size: 0.35 }))); } else { - new GLTFLoader().load(url, (gltf) => resolve(gltf.scene), undefined, reject); + new GLTFLoader().parse(buffer, "", (gltf) => resolve(gltf.scene), reject); } }); terrain.traverse((child) => { @@ -370,6 +386,7 @@ export function createRouteViewer(): RouteViewer { canvas.removeEventListener("pointerup", handlePointerUp, true); canvas.removeEventListener("pointerleave", handlePointerExit, true); canvas.removeEventListener("pointercancel", handlePointerExit, true); + releaseCursorPivot(); markers.dispose(); clearContours(); disposeObject(terrain); diff --git a/B07_wf4_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts b/B07_wf4_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts index 2b5fd28c..a10e9010 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts @@ -27,12 +27,26 @@ import { DEFAULT_TEXT_OPTIONS, type DrawController } from './DrawController'; * * To convert between the 2 coordinate systems, you need the screenOffset and screenScale */ +// Batch-mode stroke decimation: skip chain segments shorter than this (screen px) +const BATCH_LOD_PX = 0.5; +// Batch-mode text smaller than this (screen px) is unreadable — skip drawing it +const BATCH_MIN_TEXT_PX = 2; + export class ScreenCanvasDrawController implements DrawController { private screenOffset: Point = new Point(0, 0); private screenScale = 1; private screenMouseLocation: Point; private canvasSize: Point = new Point(100, 100); + // Style-run batching (static scene rendering): consecutive stroke calls + // with the same style are collected into one Path2D and stroked once. + private batching = false; + private batchPath: Path2D | null = null; + private batchKey: string | null = null; + private batchStyle: { color: string; lineWidth: number; dash: number[] } | null = null; + private batchLastX = Number.NaN; + private batchLastY = Number.NaN; + constructor(private context: CanvasRenderingContext2D) { this.screenMouseLocation = new Point(this.canvasSize.x / 2, this.canvasSize.y / 2); this.setScreenOffset(new Point(0, 0)); // User expects mathematical coordinates, where y axis goes up, but canvas y axis goes down @@ -221,6 +235,18 @@ export class ScreenCanvasDrawController implements DrawController { lineWidth: number, dash: number[] = [] ) { + if (this.batching) { + const effectiveWidth = isHighlighted ? lineWidth + 1 : lineWidth; + const effectiveDash = isSelected ? [5, 5] : dash; + const key = `${color}|${effectiveWidth}|${effectiveDash.join(',')}`; + if (key !== this.batchKey) { + this.flushBatch(); + this.batchKey = key; + this.batchStyle = { color, lineWidth: effectiveWidth, dash: effectiveDash }; + } + return; + } + this.context.strokeStyle = color; this.context.lineWidth = lineWidth; this.context.setLineDash(dash); @@ -234,14 +260,75 @@ export class ScreenCanvasDrawController implements DrawController { } } + /** + * Start style-run batching: consecutive stroke calls sharing a style are + * accumulated into a single Path2D and stroked once (with sub-pixel + * segment decimation). Used while rendering the static scene cache. + */ + public beginBatch() { + this.flushBatch(); + this.batching = true; + this.batchKey = null; + this.batchStyle = null; + } + + public endBatch() { + this.flushBatch(); + this.batching = false; + this.batchKey = null; + this.batchStyle = null; + } + + private flushBatch() { + if (this.batchPath && this.batchStyle) { + this.context.strokeStyle = this.batchStyle.color; + this.context.lineWidth = this.batchStyle.lineWidth; + this.context.setLineDash(this.batchStyle.dash); + // Round caps/joins replace the per-segment endpoint dots drawn in + // the unbatched path (see _drawRoundedEndpoint) + this.context.lineCap = 'round'; + this.context.lineJoin = 'round'; + this.context.stroke(this.batchPath); + this.context.lineCap = 'butt'; + this.context.lineJoin = 'miter'; + } + this.batchPath = null; + this.batchLastX = Number.NaN; + this.batchLastY = Number.NaN; + } + public setFillStyles(fillColor: string) { this.context.fillStyle = fillColor; } + /** + * Temporarily redirect all draw calls to another 2d context (eg an + * offscreen canvas used as static scene cache), reusing the current + * offset/scale/canvasSize without touching state or react triggers. + */ + public withContext(temporaryContext: CanvasRenderingContext2D, renderFunction: () => void) { + const originalContext = this.context; + this.context = temporaryContext; + try { + renderFunction(); + } finally { + this.context = originalContext; + } + } + + /** + * Blit a pre-rendered bitmap (static scene cache) onto the canvas at a + * pixel offset. Used while panning to avoid re-stroking every entity. + */ + public blitImage(source: CanvasImageSource, dx: number, dy: number) { + this.context.drawImage(source, dx, dy); + } + public clear() { if (this.canvasSize === null) return; if (!this.context) return; + if (this.batching) this.flushBatch(); this.context.fillStyle = CANVAS_BACKGROUND_COLOR; this.context.fillRect(0, 0, this.canvasSize?.x, this.canvasSize?.y); @@ -282,6 +369,33 @@ export class ScreenCanvasDrawController implements DrawController { * @param screenEndPoint */ public drawLineScreen(screenStartPoint: Point, screenEndPoint: Point): void { + if (this.batching) { + const startX = screenStartPoint.x; + const startY = this.canvasSize.y - screenStartPoint.y; + const endX = screenEndPoint.x; + const endY = this.canvasSize.y - screenEndPoint.y; + if (!this.batchPath) this.batchPath = new Path2D(); + // Chain break: start is not where the previous segment ended + const chainBroken = + Math.abs(startX - this.batchLastX) > BATCH_LOD_PX || + Math.abs(startY - this.batchLastY) > BATCH_LOD_PX; + if (chainBroken || Number.isNaN(this.batchLastX)) { + this.batchPath.moveTo(startX, startY); + this.batchLastX = startX; + this.batchLastY = startY; + } + const isTinyStep = + Math.abs(endX - this.batchLastX) < BATCH_LOD_PX && + Math.abs(endY - this.batchLastY) < BATCH_LOD_PX; + // Decimate sub-pixel steps inside a chain; isolated segments always draw + if (!isTinyStep || chainBroken) { + this.batchPath.lineTo(endX, endY); + this.batchLastX = endX; + this.batchLastY = endY; + } + return; + } + this.context.beginPath(); this.context.moveTo(screenStartPoint.x, this.canvasSize.y - screenStartPoint.y); this.context.lineTo(screenEndPoint.x, this.canvasSize.y - screenEndPoint.y); @@ -334,6 +448,22 @@ export class ScreenCanvasDrawController implements DrawController { endAngle: number, counterClockWise: boolean ) { + if (this.batching) { + if (screenRadius < BATCH_LOD_PX) return; // invisible at this zoom + if (!this.batchPath) this.batchPath = new Path2D(); + const centerX = screenCenterPoint.x; + const centerY = this.canvasSize.y - screenCenterPoint.y; + this.batchPath.moveTo( + centerX + screenRadius * Math.cos(startAngle), + centerY + screenRadius * Math.sin(startAngle) + ); + this.batchPath.arc(centerX, centerY, screenRadius, startAngle, endAngle, counterClockWise); + // Arc end becomes the new chain tail + this.batchLastX = centerX + screenRadius * Math.cos(endAngle); + this.batchLastY = centerY + screenRadius * Math.sin(endAngle); + return; + } + this.context.beginPath(); this.context.arc( screenCenterPoint.x, @@ -411,6 +541,10 @@ export class ScreenCanvasDrawController implements DrawController { ...DEFAULT_TEXT_OPTIONS, ...options, }; + if (this.batching) { + if (opts.fontSize < BATCH_MIN_TEXT_PX) return; // unreadable at this zoom + this.flushBatch(); // keep draw order: strokes so far go under this text + } this.context.save(); this.context.translate(basePoint.x, this.canvasSize.y - basePoint.y); const angle = getAngleWithXAxis( @@ -443,6 +577,7 @@ export class ScreenCanvasDrawController implements DrawController { height: number, angle: number ): void { + if (this.batching) this.flushBatch(); const [screenBasePoint, screenDimensions] = this.worldsToTargets([ new Point(xMin, yMin), new Point(width, height), @@ -493,6 +628,7 @@ export class ScreenCanvasDrawController implements DrawController { * @param color */ public fillRectScreen(xMin: number, yMin: number, width: number, height: number, color: string) { + if (this.batching) this.flushBatch(); // TODO see if we need to replace this with a call to fillPolygon this.context.fillStyle = color; this.context.fillRect(xMin, this.canvasSize.y - yMin, width, height); @@ -503,6 +639,7 @@ export class ScreenCanvasDrawController implements DrawController { * @param points */ public fillPolygon(...points: Point[]) { + if (this.batching) this.flushBatch(); const screenPoints = points.map(this.worldToTarget.bind(this)); this.context.beginPath(); screenPoints.forEach((screenPoint, index) => { diff --git a/B07_wf4_DesignDetail/openwebcad/src/helpers/calculate-angle-guides-and-snap-points.ts b/B07_wf4_DesignDetail/openwebcad/src/helpers/calculate-angle-guides-and-snap-points.ts index d4a79d2a..61118822 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/helpers/calculate-angle-guides-and-snap-points.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/helpers/calculate-angle-guides-and-snap-points.ts @@ -1,8 +1,8 @@ import { getAngleGuideOriginPoint, getAngleStep, - getEntities, getHoveredSnapPoints, + getLayerById, getScreenCanvasDrawController, getShouldDrawHelpers, setAngleGuideEntities, @@ -11,6 +11,7 @@ import { } from '../state.ts'; import { HOVERED_SNAP_POINT_TIME, SNAP_POINT_DISTANCE } from '../App.consts.ts'; import { getDrawHelpers } from './get-draw-guides.ts'; +import { queryEntitiesNearPoint } from './spatial-index.ts'; import { compact } from 'es-toolkit'; /** @@ -19,9 +20,16 @@ import { compact } from 'es-toolkit'; export function calculateAngleGuidesAndSnapPoints() { const angleStep = getAngleStep(); const screenCanvasDrawController = getScreenCanvasDrawController(); - const entities = getEntities(); const screenScale = screenCanvasDrawController.getScreenScale(); const worldMouseLocation = screenCanvasDrawController.getWorldMouseLocation(); + // 스냅 후보: 공간 인덱스로 마우스 주변만 조회 (전 엔티티 O(n²) 교차 계산 제거), + // 잠금 레이어(b07-frame 등 참조용)는 스냅 대상에서 제외한다. + const maxSnapDistance = SNAP_POINT_DISTANCE / screenScale; + const entities = queryEntitiesNearPoint( + worldMouseLocation.x, + worldMouseLocation.y, + maxSnapDistance * 2, + ).filter(entity => !getLayerById(entity.layerId)?.isLocked); const hoveredSnapPoints = getHoveredSnapPoints(); const eligibleHoveredSnapPoints = hoveredSnapPoints.filter( @@ -39,7 +47,7 @@ export function calculateAngleGuidesAndSnapPoints() { compact([getAngleGuideOriginPoint(), ...eligibleHoveredPoints]), worldMouseLocation, angleStep, - SNAP_POINT_DISTANCE / screenScale, + maxSnapDistance, ); setAngleGuideEntities(angleGuides); setSnapPoint(entitySnapPoint); diff --git a/B07_wf4_DesignDetail/openwebcad/src/helpers/draw-functions.ts b/B07_wf4_DesignDetail/openwebcad/src/helpers/draw-functions.ts index 7912eea9..71ff2abe 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/helpers/draw-functions.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/helpers/draw-functions.ts @@ -4,12 +4,12 @@ import {type SnapPoint, SnapPointType} from '../App.types'; import type {DrawController} from '../drawControllers/DrawController'; import type {ScreenCanvasDrawController} from '../drawControllers/screenCanvas.drawController'; import type {Entity} from '../entities/Entity'; -import {getLayers, isEntityHighlighted, isEntitySelected} from '../state'; +import {getLayerById, isEntityHighlighted, isEntitySelected} from '../state'; import {toast} from 'react-toastify'; export function drawEntities(drawController: DrawController, entities: Entity[]) { for (const entity of entities) { - const layer = getLayers().find((layer) => layer.id === entity.layerId); + const layer = getLayerById(entity.layerId); if (!layer) { toast.error(`Failed to find layer for entity: ${entity?.id}`); console.error('Failed to find layer for entity: ', entity); diff --git a/B07_wf4_DesignDetail/openwebcad/src/helpers/draw.ts b/B07_wf4_DesignDetail/openwebcad/src/helpers/draw.ts index d86bcc8a..337be55c 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/helpers/draw.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/helpers/draw.ts @@ -14,6 +14,7 @@ import { getDebugEntities, getEntities, getGhostHelperEntities, + getHighlightedEntityIds, getHoveredSnapPoints, getInputController, getShouldDrawCursor, @@ -21,13 +22,31 @@ import { getSnapPointOnAngleGuide, } from '../state'; import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController'; +import { drawScene } from './scene-cache'; + +/** + * Hover highlight is excluded from the static scene cache (it changes every + * mouse move) — re-draw the few highlighted entities on top of the blit. + */ +function drawHighlightedEntities(drawController: ScreenCanvasDrawController) { + const highlightedIds = getHighlightedEntityIds(); + if (!highlightedIds.length) return; + const idSet = new Set(highlightedIds); + drawEntities( + drawController, + getEntities().filter(entity => idSet.has(entity.id)), + ); +} export function draw(drawController: ScreenCanvasDrawController) { drawController.clear(); + // Static scene (all entities): cached bitmap blit, rebuilt only when needed. + drawScene(drawController, performance.now()); + drawHighlightedEntities(drawController); + drawHelpers(drawController, getAngleGuideEntities()); drawEntities(drawController, getGhostHelperEntities()); - drawEntities(drawController, getEntities()); drawDebugEntities(drawController, getDebugEntities()); const { snapPoint: closestSnapPoint } = getClosestSnapPoint( diff --git a/B07_wf4_DesignDetail/openwebcad/src/helpers/scene-cache.ts b/B07_wf4_DesignDetail/openwebcad/src/helpers/scene-cache.ts new file mode 100644 index 00000000..b2eb832b --- /dev/null +++ b/B07_wf4_DesignDetail/openwebcad/src/helpers/scene-cache.ts @@ -0,0 +1,143 @@ +import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController'; +import { + getGridEnabled, + getHighlightedEntityIds, + setHighlightedEntityIds, +} from '../state'; +import { drawEntities } from './draw-functions'; +import { getSceneVersion } from './scene-version'; +import { queryEntitiesInBox } from './spatial-index'; + +/** + * Static scene cache: all entities are rendered once into an offscreen canvas. + * While panning, the cached bitmap is blitted at a pixel offset instead of + * re-stroking every entity each frame. The cache is rebuilt when the scene + * version bumps (entities/layers/selection changed), when zoom or canvas size + * changes, or after the pan offset has settled for PAN_SETTLE_MS. + * + * Highlight is intentionally NOT baked into the cache (it changes on every + * mouse move) — draw.ts re-draws highlighted entities on top each frame. + */ +const PAN_SETTLE_MS = 120; + +interface RenderedParams { + version: number; + scale: number; + offsetX: number; + offsetY: number; + sizeX: number; + sizeY: number; +} + +let offscreenCanvas: HTMLCanvasElement | null = null; +let rendered: RenderedParams | null = null; +let prevOffsetX = Number.NaN; +let prevOffsetY = Number.NaN; +let lastOffsetChangeAt = 0; + +export const scenePerf = { + sceneRebuilds: 0, + lastRebuildMs: 0, + avgFrameMs: 0, +}; +(window as unknown as Record).__aisloCadPerf = scenePerf; + +export function invalidateSceneCache(): void { + rendered = null; +} + +/** + * Viewport culling: only entities whose bbox intersects the current view + * (expanded by one viewport on each side, so short pans stay covered by the + * blit before the settle-rebuild) are rendered into the scene cache. + */ +function sceneEntitiesForViewport(drawController: ScreenCanvasDrawController) { + const size = drawController.getCanvasSize(); + const scale = drawController.getScreenScale(); + const offset = drawController.getScreenOffset(); + const viewWidth = size.x / scale; + const viewHeight = size.y / scale; + return queryEntitiesInBox( + offset.x - viewWidth, + offset.y - viewHeight, + offset.x + 2 * viewWidth, + offset.y + 2 * viewHeight + ); +} + +function rebuildScene(drawController: ScreenCanvasDrawController): void { + const size = drawController.getCanvasSize(); + if (!offscreenCanvas) { + offscreenCanvas = document.createElement('canvas'); + } + if (offscreenCanvas.width !== size.x || offscreenCanvas.height !== size.y) { + offscreenCanvas.width = Math.max(1, size.x); + offscreenCanvas.height = Math.max(1, size.y); + } + const offscreenContext = offscreenCanvas.getContext('2d'); + if (!offscreenContext) return; + + const startedAt = performance.now(); + // Exclude the (rapidly changing) hover highlight from the baked bitmap. + const savedHighlight = getHighlightedEntityIds(); + if (savedHighlight.length) setHighlightedEntityIds([]); + drawController.withContext(offscreenContext, () => { + drawController.clear(); + // Style-run batching + sub-pixel decimation: one stroke per style run + drawController.beginBatch(); + drawEntities(drawController, sceneEntitiesForViewport(drawController)); + drawController.endBatch(); + }); + if (savedHighlight.length) setHighlightedEntityIds(savedHighlight); + + scenePerf.lastRebuildMs = performance.now() - startedAt; + scenePerf.sceneRebuilds++; + + const offset = drawController.getScreenOffset(); + rendered = { + version: getSceneVersion(), + scale: drawController.getScreenScale(), + offsetX: offset.x, + offsetY: offset.y, + sizeX: size.x, + sizeY: size.y, + }; +} + +/** + * Draw the static scene: rebuild the cache when needed, otherwise blit the + * cached bitmap (shifted by the pan delta). Called once per frame by draw(). + */ +export function drawScene(drawController: ScreenCanvasDrawController, now: number): void { + const size = drawController.getCanvasSize(); + const scale = drawController.getScreenScale(); + const offset = drawController.getScreenOffset(); + + if (offset.x !== prevOffsetX || offset.y !== prevOffsetY) { + lastOffsetChangeAt = now; + prevOffsetX = offset.x; + prevOffsetY = offset.y; + } + + const paramsChanged = + !rendered || + rendered.version !== getSceneVersion() || + rendered.scale !== scale || + rendered.sizeX !== size.x || + rendered.sizeY !== size.y; + const offsetChanged = + !!rendered && (rendered.offsetX !== offset.x || rendered.offsetY !== offset.y); + + // Grid lines are screen-fixed (drawn in clear()), so blitting a shifted + // bitmap would drag the grid along — always re-render while grid is on. + if (paramsChanged || getGridEnabled() || (offsetChanged && now - lastOffsetChangeAt >= PAN_SETTLE_MS)) { + rebuildScene(drawController); + } + + if (!offscreenCanvas || !rendered) return; + // screenX = (worldX - offsetX) * scale, canvasY is y-flipped afterwards: + // content shifts left when offset.x grows, down when offset.y grows. + const dx = (rendered.offsetX - offset.x) * scale; + const dy = (offset.y - rendered.offsetY) * scale; + drawController.blitImage(offscreenCanvas, dx, dy); +} diff --git a/B07_wf4_DesignDetail/openwebcad/src/helpers/scene-version.ts b/B07_wf4_DesignDetail/openwebcad/src/helpers/scene-version.ts new file mode 100644 index 00000000..5d64ca06 --- /dev/null +++ b/B07_wf4_DesignDetail/openwebcad/src/helpers/scene-version.ts @@ -0,0 +1,13 @@ +/** + * Scene version counter — incremented whenever content that is baked into the + * cached static scene bitmap changes (entities, layers, selection, grid). + * Kept dependency-free so both state.ts and scene-cache.ts can import it + * without a cycle. + */ +let sceneVersion = 0; + +export const bumpSceneVersion = (): void => { + sceneVersion++; +}; + +export const getSceneVersion = (): number => sceneVersion; diff --git a/B07_wf4_DesignDetail/openwebcad/src/helpers/spatial-index.ts b/B07_wf4_DesignDetail/openwebcad/src/helpers/spatial-index.ts new file mode 100644 index 00000000..2c9c8453 --- /dev/null +++ b/B07_wf4_DesignDetail/openwebcad/src/helpers/spatial-index.ts @@ -0,0 +1,140 @@ +import type { Entity } from '../entities/Entity'; +import { getEntities } from '../state'; +import { getSceneVersion } from './scene-version'; + +/** + * Uniform-grid spatial index over top-level entity bounding boxes. + * Rebuilt lazily whenever the scene version changes (entity edits bump it). + * Queries return entities in original array order so z-order is preserved. + */ +const GRID_CELLS_PER_AXIS = 64; + +interface IndexedEntity { + entity: Entity; + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +let indexVersion = -1; +let indexedEntities: IndexedEntity[] = []; +let unindexedEntities: Entity[] = []; // bbox unavailable — always included in results +let cells: Map = new Map(); +let cellSize = 1; +let gridMinX = 0; +let gridMinY = 0; +let gridCols = 1; + +function cellRange(min: number, max: number, gridMin: number): [number, number] { + return [Math.floor((min - gridMin) / cellSize), Math.floor((max - gridMin) / cellSize)]; +} + +function ensureIndex(): void { + const version = getSceneVersion(); + if (version === indexVersion) return; + + indexedEntities = []; + unindexedEntities = []; + cells = new Map(); + + const entities = getEntities(); + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + + for (const entity of entities) { + let box: { xmin: number; ymin: number; xmax: number; ymax: number } | null = null; + try { + box = entity.getBoundingBox(); + } catch { + box = null; + } + if ( + !box || + !Number.isFinite(box.xmin) || + !Number.isFinite(box.ymin) || + !Number.isFinite(box.xmax) || + !Number.isFinite(box.ymax) + ) { + unindexedEntities.push(entity); + continue; + } + indexedEntities.push({ + entity, + minX: box.xmin, + minY: box.ymin, + maxX: box.xmax, + maxY: box.ymax, + }); + if (box.xmin < minX) minX = box.xmin; + if (box.ymin < minY) minY = box.ymin; + if (box.xmax > maxX) maxX = box.xmax; + if (box.ymax > maxY) maxY = box.ymax; + } + + if (indexedEntities.length) { + const extent = Math.max(maxX - minX, maxY - minY, 1e-9); + cellSize = extent / GRID_CELLS_PER_AXIS; + gridMinX = minX; + gridMinY = minY; + gridCols = GRID_CELLS_PER_AXIS + 2; + + indexedEntities.forEach((item, index) => { + const [cx0, cx1] = cellRange(item.minX, item.maxX, gridMinX); + const [cy0, cy1] = cellRange(item.minY, item.maxY, gridMinY); + for (let cy = cy0; cy <= cy1; cy++) { + for (let cx = cx0; cx <= cx1; cx++) { + const key = cy * gridCols + cx; + const bucket = cells.get(key); + if (bucket) bucket.push(index); + else cells.set(key, [index]); + } + } + }); + } + + indexVersion = version; +} + +/** 뷰포트/사각 영역과 bbox가 겹치는 엔티티 (원본 배열 순서 유지). */ +export function queryEntitiesInBox( + minX: number, + minY: number, + maxX: number, + maxY: number +): Entity[] { + ensureIndex(); + if (!indexedEntities.length) return [...unindexedEntities]; + + const seen = new Set(); + const [cx0, cx1] = cellRange(minX, maxX, gridMinX); + const [cy0, cy1] = cellRange(minY, maxY, gridMinY); + for (let cy = cy0; cy <= cy1; cy++) { + for (let cx = cx0; cx <= cx1; cx++) { + const bucket = cells.get(cy * gridCols + cx); + if (!bucket) continue; + for (const index of bucket) seen.add(index); + } + } + + const result: Entity[] = []; + let unindexedCursor = 0; + for (let index = 0; index < indexedEntities.length; index++) { + if (!seen.has(index)) continue; + const item = indexedEntities[index]; + if (item.maxX < minX || item.minX > maxX || item.maxY < minY || item.minY > maxY) continue; + result.push(item.entity); + } + // bbox 불명 엔티티는 항상 포함 (뒤에 붙여도 소수라 시각 영향 없음) + for (; unindexedCursor < unindexedEntities.length; unindexedCursor++) { + result.push(unindexedEntities[unindexedCursor]); + } + return result; +} + +/** 점 주변 반경 후보 엔티티 (스냅·호버용). */ +export function queryEntitiesNearPoint(x: number, y: number, radius: number): Entity[] { + return queryEntitiesInBox(x - radius, y - radius, x + radius, y + radius); +} diff --git a/B07_wf4_DesignDetail/openwebcad/src/main.tsx b/B07_wf4_DesignDetail/openwebcad/src/main.tsx index a1884e83..6ffec00a 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/main.tsx +++ b/B07_wf4_DesignDetail/openwebcad/src/main.tsx @@ -7,6 +7,8 @@ import App from './App.tsx'; import { ScreenCanvasDrawController } from './drawControllers/screenCanvas.drawController'; import { draw } from './helpers/draw'; import { findClosestEntity } from './helpers/find-closest-entity'; +import { scenePerf } from './helpers/scene-cache'; +import { queryEntitiesNearPoint } from './helpers/spatial-index'; import { getNewLayer } from './helpers/get-new-layer.ts'; import { trackHoveredSnapPoint } from './helpers/track-hovered-snap-points'; import { InputController } from './inputController/input-controller.ts'; @@ -14,7 +16,6 @@ import { registerAisloDrawingBridge } from './integration/aislo-drawing-bridge.t import { getActiveToolActor, getCanvas, - getEntities, getHoveredSnapPoints, getLastDrawTimestamp, getScreenCanvasDrawController, @@ -40,6 +41,13 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLDivElement).render( ); +// Hover highlight throttle: the closest-entity scan is O(entities) so it runs +// at most every HOVER_THROTTLE_MS and only when the mouse actually moved. +const HOVER_THROTTLE_MS = 30; +let lastHoverCheckAt = 0; +let lastHoverMouseX = Number.NaN; +let lastHoverMouseY = Number.NaN; + function startDrawLoop( screenCanvasDrawController: ScreenCanvasDrawController, timestamp: DOMHighResTimeStamp @@ -48,6 +56,7 @@ function startDrawLoop( const elapsedTime = timestamp - lastDrawTimestamp; setLastDrawTimestamp(timestamp); + scenePerf.avgFrameMs = scenePerf.avgFrameMs * 0.9 + elapsedTime * 0.1; // biome-ignore lint/suspicious/noExplicitAny: const activeToolSnapshot: MachineSnapshot | undefined = @@ -70,13 +79,26 @@ function startDrawLoop( if (!screenCanvasDrawController) { throw new Error('getScreenCanvasDrawController() returned null'); } - const { distance, entity: closestEntity } = findClosestEntity( - screenCanvasDrawController.getWorldMouseLocation(), - getEntities() - ); + const mouseLocation = screenCanvasDrawController.getScreenMouseLocation(); + const mouseMoved = mouseLocation.x !== lastHoverMouseX || mouseLocation.y !== lastHoverMouseY; + if (mouseMoved && timestamp - lastHoverCheckAt >= HOVER_THROTTLE_MS) { + lastHoverCheckAt = timestamp; + lastHoverMouseX = mouseLocation.x; + lastHoverMouseY = mouseLocation.y; + const worldMouseLocation = screenCanvasDrawController.getWorldMouseLocation(); + const { distance, entity: closestEntity } = findClosestEntity( + worldMouseLocation, + // 공간 인덱스로 후보를 좁혀 O(전체) 스캔 제거 + queryEntitiesNearPoint( + worldMouseLocation.x, + worldMouseLocation.y, + HIGHLIGHT_ENTITY_DISTANCE + ) + ); - if (distance < HIGHLIGHT_ENTITY_DISTANCE) { - setHighlightedEntityIds([closestEntity.id]); + if (distance < HIGHLIGHT_ENTITY_DISTANCE) { + setHighlightedEntityIds([closestEntity.id]); + } } } diff --git a/B07_wf4_DesignDetail/openwebcad/src/state.ts b/B07_wf4_DesignDetail/openwebcad/src/state.ts index 28563c07..3c3049d0 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/state.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/state.ts @@ -12,6 +12,7 @@ import { } from './App.types'; import type { ScreenCanvasDrawController } from './drawControllers/screenCanvas.drawController'; import type { Entity } from './entities/Entity'; +import { bumpSceneVersion } from './helpers/scene-version'; import { createStack, StateVariable, type UndoState } from './helpers/undo-stack'; import type { InputController } from './inputController/input-controller.ts'; // state variables @@ -41,11 +42,13 @@ let entities: Entity[] = []; * Entities that are highlighted: when the mouse is close to an entity */ let highlightedEntityIds: string[] = []; +let highlightedEntityIdSet: Set = new Set(); /** * Entities that are selected by the user by clicking on them with the select tool or by selecting them with a selection rectangle */ let selectedEntityIds: string[] = []; +let selectedEntityIdSet: Set = new Set(); /** * Whether to draw the cursor or not @@ -162,6 +165,12 @@ let layers: Layer[] = [ */ let activeLayerId: string = layers[0].id; +/** + * layerId → Layer lookup, kept in sync with `layers` (drawEntities runs this + * lookup once per entity per frame — a linear find() was a hot spot) + */ +let layersById: Map = new Map(layers.map((layer) => [layer.id, layer])); + let snapEnabled = true; let gridEnabled = false; @@ -207,16 +216,18 @@ export const getInputController = (): InputController => { }; export const getSelectedEntities = (): Entity[] => { - return entities.filter((e) => selectedEntityIds.includes(e.id)); + return entities.filter((e) => selectedEntityIdSet.has(e.id)); }; export const getNotSelectedEntities = (): Entity[] => { - return entities.filter((e) => !selectedEntityIds.includes(e.id)); + return entities.filter((e) => !selectedEntityIdSet.has(e.id)); }; -export const isEntitySelected = (entity: Entity) => selectedEntityIds.includes(entity.id); -export const isEntityHighlighted = (entity: Entity) => highlightedEntityIds.includes(entity.id); +export const isEntitySelected = (entity: Entity) => selectedEntityIdSet.has(entity.id); +export const isEntityHighlighted = (entity: Entity) => highlightedEntityIdSet.has(entity.id); +export const getHighlightedEntityIds = () => highlightedEntityIds; export const getLayers = () => { return layers; }; +export const getLayerById = (layerId: string): Layer | undefined => layersById.get(layerId); export const getActiveLayerId = (): string => { return activeLayerId; }; @@ -279,15 +290,19 @@ export const setEntities = (newEntities: Entity[], trackInUndoStack = false) => trackUndoState(StateVariable.entities, newEntities); } entities = newEntities; + bumpSceneVersion(); if (trackInUndoStack) { window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED)); } }; export const setHighlightedEntityIds = (newEntityIds: string[]) => { highlightedEntityIds = newEntityIds; + highlightedEntityIdSet = new Set(newEntityIds); }; export const setSelectedEntityIds = (newEntityIds: string[]) => { selectedEntityIds = newEntityIds; + selectedEntityIdSet = new Set(newEntityIds); + bumpSceneVersion(); // selection style (dashed) is baked into the scene cache window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE)); }; export const setShouldDrawCursor = (newValue: boolean) => { @@ -374,6 +389,8 @@ export const setActiveTextStyle = ( }; export const setLayers = (newLayers: Layer[], triggerReact = true) => { layers = newLayers; + layersById = new Map(newLayers.map((layer) => [layer.id, layer])); + bumpSceneVersion(); // layer visibility/lock affects what the scene cache shows if (triggerReact) { triggerReactUpdate(StateVariable.layers); @@ -398,6 +415,7 @@ export const setSnapEnabled = (enabled: boolean) => { }; export const setGridEnabled = (enabled: boolean) => { gridEnabled = enabled; + bumpSceneVersion(); window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE)); }; export const setDesignMeta = (newMeta: DesignMeta | null) => { @@ -462,6 +480,7 @@ function updateStates(undoState: UndoState) { switch (variable) { case StateVariable.entities: entities = value; + bumpSceneVersion(); break; } } diff --git a/B11_Status/B11_Status_UI_Loading.ts b/B11_Status/B11_Status_UI_Loading.ts new file mode 100644 index 00000000..304faa1a --- /dev/null +++ b/B11_Status/B11_Status_UI_Loading.ts @@ -0,0 +1,94 @@ +import { CURRENT_PROJECT_ID_KEY, ROUTES, type RoutePath } from "@config/config_frontend"; +import { createButton } from "@ui/ui_template_elements"; +import { createGeneralLayout } from "@ui/ui_template_general_layout"; +import { t } from "@ui/ui_template_locale"; +import { createProgressCircle } from "@ui/ui_template_progress"; +import { + markProjectPreloaded, + preloadSurfaceAssets, + purgeOtherProjects, + readPreloadTarget, +} from "../A00_Common/b_asset_cache"; +import { navigateTo } from "../A00_Common/router"; +import "./B11_Status_UI_Style.css"; + +/* ============================================================================= + * 자료 준비 화면 (B11) + * + * 대시보드에서 프로젝트를 골라 작업 화면으로 들어갈 때, 먼저 이 화면이 3D 지표면과 + * 등고선을 브라우저에 담아 둔다. 담아 두면 B04·B05가 그것을 그대로 쓰므로 화면이 바로 뜬다. + * 이미 담겨 있으면 순식간에 지나간다. + * + * 자료를 준비하지 못하면(분석 실패·저장 경로 문제) 넘어가지 않고 사유를 알린다. + * ========================================================================== */ + +/** 준비에 실패한 채 그냥 넘어갔을 때 남기는 표식 — 어떤 확정 구성과도 일치하지 않는다. */ +const PRELOAD_SKIPPED_SIGNATURE = "skipped"; + +export async function renderB11Loading(root: HTMLElement): Promise { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const target = (readPreloadTarget() ?? ROUTES.B03_FILE_INPUT) as RoutePath; + + const progress = createProgressCircle({ label: t("B11_Loading_Start"), size: 96 }); + const message = document.createElement("p"); + message.className = "b11-loading__message"; + message.textContent = t("B11_Loading_Message"); + + const actions = document.createElement("div"); + actions.className = "b11-loading__actions"; + + const body = document.createElement("div"); + body.className = "b11-loading__body"; + body.append(progress.root, message, actions); + + const layout = createGeneralLayout({ + pageClass: "b11-status", + title: t("B11_Loading_Title"), + subtitle: t("B11_Loading_Subtitle"), + content: [body], + }); + root.replaceChildren(layout.root); + + if (!projectId) { + showFailure(t("B11_Loading_NoProject")); + return; + } + + try { + // 다른 프로젝트 자료가 남아 있으면 지운다 — 프로젝트끼리 섞이면 안 된다. + await purgeOtherProjects(projectId); + const signature = await preloadSurfaceAssets(projectId, (label, ratio) => { + progress.set(ratio, label); + }); + // 표식은 담아 둔 구성 그대로 남긴다 — 확정이 바뀌면 다음 진입에서 다시 준비한다. + markProjectPreloaded(projectId, signature); + navigateTo(target); + } catch (error) { + const detail = error instanceof Error ? ` (${error.message})` : ""; + showFailure(`${t("B11_Loading_Failed")}${detail}`); + } + + function showFailure(text: string): void { + progress.root.hidden = true; + message.textContent = text; + message.classList.add("b11-loading__message--error"); + actions.replaceChildren( + createButton({ + label: t("B11_Loading_Btn_Continue"), + variant: "ghost", + // 준비를 못 했어도 같은 세션에서 다시 붙잡지 않도록 표시해 둔다. + // 확정 구성을 모르는 상태이므로 전용 표식을 남긴다 — 확정이 정상화되면 표식이 + // 어긋나 준비 화면이 다시 뜬다. + onClick: () => { + if (projectId) markProjectPreloaded(projectId, PRELOAD_SKIPPED_SIGNATURE); + navigateTo(target); + }, + }), + createButton({ + label: t("B11_Loading_Btn_Dashboard"), + variant: "filled", + onClick: () => navigateTo(ROUTES.B01_ACCOUNT), + }), + ); + } +} diff --git a/B11_Status/B11_Status_UI_Style.css b/B11_Status/B11_Status_UI_Style.css index e0862773..9d87977f 100644 --- a/B11_Status/B11_Status_UI_Style.css +++ b/B11_Status/B11_Status_UI_Style.css @@ -26,6 +26,40 @@ color: var(--color-text-body); } +/* 자료 준비 화면 — 서클과 안내 문구를 가운데 모아 둔다. */ +.b11-loading__body { + display: flex; + min-height: 320px; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--spacing-24); + padding: var(--spacing-24); +} + +.b11-loading__message { + max-width: 46ch; + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); + line-height: 1.6; + text-align: center; + word-break: keep-all; +} + +.b11-loading__message--error { + color: var(--color-danger); +} + +.b11-loading__actions { + display: flex; + gap: var(--spacing-8); +} + +.b11-loading__actions:empty { + display: none; +} + @media (max-width: 860px) { .b11-status__flow { grid-template-columns: 1fr 1fr; diff --git a/common_util/common_util_http_cache.py b/common_util/common_util_http_cache.py new file mode 100644 index 00000000..0e83c4be --- /dev/null +++ b/common_util/common_util_http_cache.py @@ -0,0 +1,39 @@ +"""파일 응답에 브라우저 캐시 검증(ETag)을 붙이는 공통 유틸. + +3D 프리뷰·등고선처럼 한 번 만들면 잘 바뀌지 않는 파일은, 브라우저가 이미 받아 둔 것을 +그대로 쓰게 해야 새로고침이 빠르다. 그렇다고 무조건 캐시를 믿게 두면 모델을 다시 만들었을 때 +옛 파일이 계속 보인다. + +그래서 파일의 수정시각·크기로 ETag를 만들어 보내고, 브라우저가 같은 ETag를 들고 오면 +본문 없이 304만 돌려준다. 파일이 바뀌면 ETag가 저절로 달라져 새 파일을 받는다. +""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi import Request +from fastapi.responses import FileResponse, Response + +# 항상 서버에 물어보되(변경 감지), 안 바뀌었으면 본문을 다시 받지 않는다. +CACHE_CONTROL = "private, max-age=0, must-revalidate" + + +def file_etag(path: Path) -> str: + """파일 수정시각·크기로 만든 ETag. 파일이 바뀌면 값이 달라진다.""" + stat = path.stat() + return f'"{int(stat.st_mtime)}-{stat.st_size}"' + + +def cached_file_response( + request: Request, + path: Path, + media_type: str, + filename: str | None = None, +) -> Response: + """ETag를 붙인 파일 응답. 브라우저가 가진 것과 같으면 304만 돌려준다.""" + etag = file_etag(path) + headers = {"ETag": etag, "Cache-Control": CACHE_CONTROL} + if request.headers.get("if-none-match") == etag: + return Response(status_code=304, headers=headers) + return FileResponse(path, media_type=media_type, filename=filename, headers=headers) diff --git a/common_util/common_util_route_geometry.py b/common_util/common_util_route_geometry.py new file mode 100644 index 00000000..22913bcd --- /dev/null +++ b/common_util/common_util_route_geometry.py @@ -0,0 +1,255 @@ +"""계획 노선 기하 공용 유틸 — 정점·누가거리·세류 교차점. + +배수유역 분석(B04)과 관 편집·세부유역(B05)이 같은 노선 표현을 써야 하므로 여기 한 곳에만 +정의한다. 어느 한쪽 페이지 폴더에 두면 반대 방향 import가 생긴다. + +노선 원천은 두 가지다. + · B03에 업로드된 **계획 노선 파일**(CSV) — 배수유역 분석의 입력 + · DB `route_points` — B05에서 탐색·확정한 노선 +둘 다 같은 `RouteVertex` 목록으로 바꿔 아래 함수들이 그대로 받는다. +""" + +from __future__ import annotations + +import csv +import logging +import math +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from shapely.geometry import LineString, Point, shape + +logger = logging.getLogger(__name__) + +# 계획 노선 CSV 열 이름 후보. B03이 여러 형식을 받게 되므로 흔한 표기를 모두 받아 준다. +_X_KEYS = ("x", "X", "동", "easting", "EASTING") +_Y_KEYS = ("y", "Y", "북", "northing", "NORTHING") +_Z_KEYS = ("z", "Z", "표고", "elevation", "ELEV") +_ORDER_KEYS = ("sequence", "order", "seq", "no", "번호") +_EPSG_KEYS = ("crs_epsg", "epsg", "EPSG") + + +@dataclass +class RouteVertex: + """노선 폴리라인의 한 점. chainage는 시점 기준 누가거리(m).""" + + x: float + y: float + z: float + chainage_m: float + + +@dataclass +class StructureCandidate: + """관 매설 구조물 측점 후보.""" + + chainage_m: float + x: float + y: float + # "stream"=세류 교차, "spacing"=최대 간격 규칙 보충, "confirmed"=사용자 확정 + reason: str + stream_name: str | None = None + + +@dataclass +class PlannedRoute: + """계획 노선 파일에서 읽은 노선.""" + + vertices: list[RouteVertex] + epsg: int | None + name: str | None + source: Path + + @property + def line(self) -> LineString: + return LineString([(vertex.x, vertex.y) for vertex in self.vertices]) + + +def read_planned_route_csv(path: Path) -> PlannedRoute | None: + """계획 노선 CSV를 읽어 정점 목록으로 바꾼다. + + 열 이름은 대소문자·한글 표기를 함께 받아 준다(B03이 여러 형식을 수용할 예정). + `sequence`가 있으면 그 순서로 정렬하고, 없으면 파일에 적힌 순서를 그대로 쓴다. + """ + try: + with path.open("r", encoding="utf-8-sig", newline="") as file: + rows = list(csv.DictReader(file)) + except (OSError, csv.Error, UnicodeDecodeError): + logger.warning("계획 노선 CSV를 읽지 못했습니다: %s", path) + return None + if not rows: + return None + + epsg = _first_int(rows[0], _EPSG_KEYS) + name = _first_text(rows[0], ("route_name", "name", "노선명")) + parsed: list[tuple[float, float, float, float]] = [] # (정렬키, x, y, z) + for index, row in enumerate(rows): + x = _first_float(row, _X_KEYS) + y = _first_float(row, _Y_KEYS) + if x is None or y is None: + continue + order = _first_float(row, _ORDER_KEYS) + parsed.append( + (float(index) if order is None else order, x, y, _first_float(row, _Z_KEYS) or 0.0) + ) + if len(parsed) < 2: + logger.warning("계획 노선 CSV에 좌표가 2점 미만입니다: %s", path) + return None + + parsed.sort(key=lambda item: item[0]) + vertices: list[RouteVertex] = [] + cumulative = 0.0 + previous: tuple[float, float] | None = None + for _, x, y, z in parsed: + if previous is not None: + cumulative += math.dist(previous, (x, y)) + vertices.append(RouteVertex(x=x, y=y, z=z, chainage_m=cumulative)) + previous = (x, y) + logger.info( + "계획 노선 %s: 정점 %d개, 연장 %.0fm, EPSG %s", path.name, len(vertices), cumulative, epsg + ) + return PlannedRoute(vertices=vertices, epsg=epsg, name=name, source=path) + + +def find_planned_route_file(input_dir: Path) -> Path | None: + """B03 입력 폴더에서 계획 노선 파일을 찾는다. 여러 개면 가장 최근 것.""" + if not input_dir.exists(): + return None + candidates = sorted( + input_dir.rglob("*.csv"), key=lambda item: item.stat().st_mtime, reverse=True + ) + return candidates[0] if candidates else None + + +def build_route_vertices(points: list[dict[str, Any]]) -> list[RouteVertex]: + """DB route_points 행을 누가거리가 채워진 정점 목록으로 바꾼다.""" + vertices: list[RouteVertex] = [] + cumulative = 0.0 + previous: tuple[float, float] | None = None + for row in points: + x = float(row["x"]) + y = float(row["y"]) + z = float(row.get("z") or 0.0) + if previous is not None: + cumulative += math.dist(previous, (x, y)) + chainage = row.get("chainage_m") + vertices.append( + RouteVertex( + x=x, y=y, z=z, chainage_m=float(chainage) if chainage is not None else cumulative + ) + ) + previous = (x, y) + return vertices + + +def interpolate_vertex( + vertices: list[RouteVertex], chainage_m: float +) -> tuple[float, float, float]: + """누가거리 위치의 (x, y, z)를 선형 보간한다. 범위 밖은 끝점으로 당긴다.""" + if not vertices: + return (0.0, 0.0, 0.0) + if chainage_m <= vertices[0].chainage_m: + return (vertices[0].x, vertices[0].y, vertices[0].z) + for previous, current in zip(vertices, vertices[1:]): + if chainage_m <= current.chainage_m: + span = current.chainage_m - previous.chainage_m + ratio = 0.0 if span <= 0 else (chainage_m - previous.chainage_m) / span + return ( + previous.x + (current.x - previous.x) * ratio, + previous.y + (current.y - previous.y) * ratio, + previous.z + (current.z - previous.z) * ratio, + ) + last = vertices[-1] + return (last.x, last.y, last.z) + + +def is_uphill_at(vertices: list[RouteVertex], chainage_m: float, window_m: float = 20.0) -> bool: + """해당 위치가 오르막(절토부)인지 종단 계획선의 국소 기울기 부호로 판정한다.""" + _, _, back_z = interpolate_vertex(vertices, max(0.0, chainage_m - window_m)) + _, _, forward_z = interpolate_vertex(vertices, chainage_m + window_m) + return forward_z >= back_z + + +def find_stream_crossings( + vertices: list[RouteVertex], + stream_features: list[dict[str, Any]], +) -> list[StructureCandidate]: + """노선 평면 선형과 세류선의 교차 지점을 누가거리 순으로 찾는다.""" + if len(vertices) < 2: + return [] + route_line = LineString([(vertex.x, vertex.y) for vertex in vertices]) + candidates: list[StructureCandidate] = [] + for feature in stream_features: + geometry = feature.get("geometry") + if not geometry: + continue + try: + stream = shape(geometry) + except Exception: # noqa: BLE001 - 손상된 피처는 건너뛴다 + continue + if stream.is_empty: + continue + intersection = route_line.intersection(stream) + if intersection.is_empty: + continue + name = _stream_name(feature) + for point in _collect_points(intersection): + candidates.append( + StructureCandidate( + chainage_m=route_line.project(point), + x=point.x, + y=point.y, + reason="stream", + stream_name=name, + ) + ) + candidates.sort(key=lambda item: item.chainage_m) + return candidates + + +def _stream_name(feature: dict[str, Any]) -> str | None: + properties = feature.get("properties") or {} + for key in ("명칭", "하천명", "NAME", "name"): + value = properties.get(key) + if value: + return str(value) + return None + + +def _collect_points(geometry: Any) -> list[Point]: + """교차 결과(Point/MultiPoint/LineString 등)에서 대표 점들을 뽑는다.""" + if geometry.geom_type == "Point": + return [geometry] + if geometry.geom_type in {"MultiPoint", "GeometryCollection"}: + points: list[Point] = [] + for part in geometry.geoms: + points.extend(_collect_points(part)) + return points + # 선분끼리 겹쳐 선으로 나온 경우는 중점을 대표로 쓴다. + if geometry.geom_type in {"LineString", "MultiLineString"}: + return [geometry.interpolate(0.5, normalized=True)] + return [] + + +def _first_text(row: dict[str, Any], keys: tuple[str, ...]) -> str | None: + for key in keys: + value = row.get(key) + if value not in (None, ""): + return str(value).strip() + return None + + +def _first_float(row: dict[str, Any], keys: tuple[str, ...]) -> float | None: + text = _first_text(row, keys) + if text is None: + return None + try: + return float(text) + except ValueError: + return None + + +def _first_int(row: dict[str, Any], keys: tuple[str, ...]) -> int | None: + value = _first_float(row, keys) + return None if value is None else int(value) diff --git a/config/config_frontend.ts b/config/config_frontend.ts index bd7bd651..e6fefe39 100644 --- a/config/config_frontend.ts +++ b/config/config_frontend.ts @@ -16,6 +16,10 @@ export const API_BASE_URL = "/api"; /** API 요청 타임아웃 (ms) */ export const API_TIMEOUT_MS = 30_000; +/** 격자 해석처럼 수십 초가 걸리는 분석 요청용 타임아웃 (ms). + * 일반 요청에 이 값을 쓰면 장애 시 화면이 오래 멈추므로 분석 엔드포인트에만 쓴다. */ +export const API_ANALYSIS_TIMEOUT_MS = 60_000; + /** B03~B09 워크플로우에서 사용할 현재 프로젝트 UUID 저장 키 */ export const CURRENT_PROJECT_ID_KEY = "frd_current_project_id"; @@ -37,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 렌더링 옵션 @@ -88,6 +92,8 @@ export const ROUTES = { B09_WF6_ESTIMATION: "b09-wf6-estimation", B10_PAYMENT: "b10-payment", B11_STATUS: "b11-status", + // 대시보드에서 B그룹으로 처음 들어갈 때 3D·등고선을 미리 받아 두는 준비 화면. + B11_LOADING: "b11-loading", } as const; export type RouteKey = keyof typeof ROUTES; @@ -109,6 +115,7 @@ export const PROTECTED_ROUTES: readonly RoutePath[] = [ ROUTES.B09_WF6_ESTIMATION, ROUTES.B10_PAYMENT, ROUTES.B11_STATUS, + ROUTES.B11_LOADING, ]; /* ----------------------------------------------------------------------------- diff --git a/config/config_system.py b/config/config_system.py index 9a5bd69f..90a58a4a 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -48,7 +48,7 @@ DB_POOL_MAX = int(os.getenv("DB_POOL_MAX", "20")) UPLOAD_MAX_MB = int(os.getenv("UPLOAD_MAX_MB", str(30 * 1024))) UPLOAD_MAX_FILES = int(os.getenv("UPLOAD_MAX_FILES", "5")) UPLOAD_CHUNK_SIZE_BYTES = int(os.getenv("UPLOAD_CHUNK_SIZE_BYTES", str(1024 * 1024 * 1024))) -UPLOAD_ALLOWED_EXT = [".las", ".laz", ".tif", ".tfw", ".prj", ".dxf"] +UPLOAD_ALLOWED_EXT = [".csv", ".las", ".laz", ".tif", ".tfw", ".prj", ".dxf"] CHUNK_TEMP_DIR = os.getenv("CHUNK_TEMP_DIR", "B03_FileInput/chunks_temp") CHUNK_RETENTION_HOURS = int(os.getenv("CHUNK_RETENTION_HOURS", "24")) MERGE_TIMEOUT_SECONDS = int(os.getenv("MERGE_TIMEOUT_SECONDS", "3600")) @@ -232,6 +232,87 @@ SKELETON_MAIN_RIDGE_ACC_THRESHOLD_CELLS = int( SKELETON_NODE_SPACING_M = float(os.getenv("SKELETON_NODE_SPACING_M", "10.0")) +# ───────────────────────────────────────────────────────────────────────── +# 5-3-1. 배수유역 격자 해석 파라미터 (B05 WF2 — 2026-07-31 전면 재설계) +# +# 도엽 등고선 TIN 보간 → 웅덩이 채움 → D8 물 방향 → 도로에서 상류 BFS(포인터 더블링) +# 순서로 유역을 정한다. 능선을 따로 탐지하지 않는다 — 도로로 물이 도달하는지 여부가 +# 유일한 판정 기준이며, 그 경계가 곧 능선이다. +# 라이다 DEM은 노선 주변만 커버해 유역 산정에 부족하므로 쓰지 않는다(사용자 지시). +# ───────────────────────────────────────────────────────────────────────── +# 해석 격자 한 변(m). 작을수록 정밀하나 셀 수가 제곱으로 늘어난다. +DRAINAGE_GRID_SIZE_M = float(os.getenv("DRAINAGE_GRID_SIZE_M", "1.0")) +# 1차 배수유역 반경(m). 도로 교차점 상류로 이어진 세류망과 계획 노선을 각각 이 반경으로 +# 버퍼해 합친 범위가 1차 영역이며, 그 bbox가 해석 격자다. +# 노선 버퍼가 필요한 이유: 세류 교차가 없는 구간의 도로도 격자 안에 있어야 그 구간 사면이 +# 유역으로 잡힌다. 세류망 선정이 정확해진 뒤 다시 포함했다(2026-07-31 사용자 지시). +DRAINAGE_INITIAL_RADIUS_M = float(os.getenv("DRAINAGE_INITIAL_RADIUS_M", "100.0")) +# 활성 셀이 격자 최외곽에 닿았을 때 한 번에 넓히는 폭(m). +DRAINAGE_EXPAND_STEP_M = float(os.getenv("DRAINAGE_EXPAND_STEP_M", "200.0")) +# 확장 반복 상한. 경계 링이 전부 비활성이 되면 그 전에 스스로 멈춘다(안전핀). +DRAINAGE_MAX_EXPAND_ROUNDS = int(os.getenv("DRAINAGE_MAX_EXPAND_ROUNDS", "6")) +# 최외곽 적색 셀 주변을 한 회차에 넓히는 폭(m). 좁을수록 유역 경계가 정밀하나 회차가 늘어난다. +DRAINAGE_RED_EXPAND_BAND_M = float(os.getenv("DRAINAGE_RED_EXPAND_BAND_M", "50.0")) +# 적색 확장 반복 상한. 새로 추가한 셀에 적색이 없으면 그 전에 스스로 멈춘다(안전핀). +DRAINAGE_RED_EXPAND_MAX_ROUNDS = int(os.getenv("DRAINAGE_RED_EXPAND_MAX_ROUNDS", "20")) +# 격자 셀 수 권장 상한. 넘으면 **경고만** 남기고 그대로 계산한다 — 격자 크기 자동 조절은 +# 하지 않는다(2026-07-31 사용자 지시). 느리면 위 DRAINAGE_GRID_SIZE_M을 직접 올린다. +DRAINAGE_MAX_GRID_CELLS = int(os.getenv("DRAINAGE_MAX_GRID_CELLS", "16000000")) +# 도로 폭(m). 이 폭으로 노선을 격자에 구워 D8 흐름이 도로를 대각선으로 건너뛰지 못하게 한다. +DRAINAGE_ROAD_WIDTH_M = float(os.getenv("DRAINAGE_ROAD_WIDTH_M", "4.0")) +# 계획선 최저점보다 이만큼 아래인 등고선은 상류 기여가 불가능하므로 보간에서 제외한다. +DRAINAGE_CONTOUR_MARGIN_M = float(os.getenv("DRAINAGE_CONTOUR_MARGIN_M", "10.0")) +# 이보다 짧은 등고선 파편은 노이즈로 보고 버린다. 봉우리 폐합 등고선은 이 값 이상이면 남는다. +DRAINAGE_CONTOUR_MIN_LENGTH_M = float(os.getenv("DRAINAGE_CONTOUR_MIN_LENGTH_M", "20.0")) +# 등고선 정점 재샘플 간격(m). 조밀할수록 TIN이 정확하나 Delaunay 비용이 커진다. +DRAINAGE_CONTOUR_RESAMPLE_M = float(os.getenv("DRAINAGE_CONTOUR_RESAMPLE_M", "5.0")) +# TIN 삼각망을 만들 때 격자 범위 밖으로 남길 여유(m). 도엽 전체 등고선을 다 물면 삼각망 +# 비용만 커지고 결과는 같다. 여유가 0이면 격자 가장자리가 TIN 밖으로 나가 NaN이 된다. +DRAINAGE_CONTOUR_CLIP_MARGIN_M = float(os.getenv("DRAINAGE_CONTOUR_CLIP_MARGIN_M", "100.0")) +# 평탄면 해소용 미세 경사(m/셀). 채움 후 흐름 방향이 없는 셀에 출구 쪽 경사를 만들어 준다. +DRAINAGE_FLAT_EPSILON_M = float(os.getenv("DRAINAGE_FLAT_EPSILON_M", "0.001")) +# 관 매설 최대 간격(m). 이 간격을 넘으면 흐름 강도가 가장 큰 지점에 관을 보충한다. +DRAINAGE_PIPE_MAX_SPACING_M = float(os.getenv("DRAINAGE_PIPE_MAX_SPACING_M", "300.0")) +# 관끼리 이보다 가까우면 같은 계곡으로 보고 하나로 합친다. +DRAINAGE_PIPE_MIN_SPACING_M = float(os.getenv("DRAINAGE_PIPE_MIN_SPACING_M", "20.0")) +# 측구 흐름(도로 셀 → 담당 관) 판정용 종단 계획선 샘플 간격(m). +DRAINAGE_DITCH_SAMPLE_M = float(os.getenv("DRAINAGE_DITCH_SAMPLE_M", "1.0")) +# 유역 폴리곤 단순화 허용오차(m). 격자 계단 경계를 매끄럽게 줄여 응답 크기를 낮춘다. +DRAINAGE_POLYGON_SIMPLIFY_M = float(os.getenv("DRAINAGE_POLYGON_SIMPLIFY_M", "2.0")) +# 이 면적(㎡) 미만의 유역 조각은 버린다(격자 노이즈 제거). +DRAINAGE_MIN_BASIN_AREA_M2 = float(os.getenv("DRAINAGE_MIN_BASIN_AREA_M2", "100.0")) +# 격자 해석 결과 캐시 파일명. 프로젝트 저장소의 B04_wf1_Surface/drainage/ 아래에 놓인다. +DRAINAGE_CACHE_DIRNAME = "drainage" +DRAINAGE_CACHE_FILENAME = "watershed_grid.npz" +# 분석 응답 자체를 그대로 담아 두는 파일. 재산정하지 않는 한 이걸 그대로 돌려준다 — +# 저장 배열에서 응답을 다시 조립하면 원본과 어긋날 여지가 생긴다(2026-07-31 사용자 지시). +DRAINAGE_RESPONSE_FILENAME = "00_watershed_response.json" + +# ── B05 전용 배수유역 사본 ── +# B04 산출물을 그대로 쓰면 B05 편집이 원본을 덮어쓴다. 프로젝트 저장소의 +# B05_wf2_Route/drainage/ 아래로 복사해 두고 B05는 사본만 읽고 쓴다. +DRAINAGE_B05_DIRNAME = "drainage" +# 사용자가 옮긴 유역 외곽선 포인트만 담는 파일. B04 재계산으로 사본이 갱신돼도 남는다. +DRAINAGE_BOUNDARY_OVERRIDE_FILENAME = "boundary_overrides.json" +# 유역 외곽선 편집 핸들 간격(m). 화면에서 보고 조정할 값(2026-08-01 사용자 지시). +DRAINAGE_BOUNDARY_HANDLE_SPACING_M = float(os.getenv("DRAINAGE_BOUNDARY_HANDLE_SPACING_M", "20.0")) +# 재계산된 외곽선에 저장 포인트를 다시 붙일 때 허용하는 최대 거리(m). +# 인덱스는 재계산으로 어긋나므로 좌표 근접으로만 맞춘다. +DRAINAGE_BOUNDARY_MATCH_RADIUS_M = float(os.getenv("DRAINAGE_BOUNDARY_MATCH_RADIUS_M", "30.0")) + +# ── B05용 평균 흐름 화살표 ── +# 셀 화살표는 1m라 축소하면 경향이 안 보인다. 이 크기의 블록으로 묶어 방향을 평균한다. +DRAINAGE_ARROW_BLOCK_M = float(os.getenv("DRAINAGE_ARROW_BLOCK_M", "10.0")) +# 화살표끼리 최소 이 간격을 두고 솎아낸다. 촘촘하면 도면이 지저분해진다. +DRAINAGE_ARROW_SPACING_M = float(os.getenv("DRAINAGE_ARROW_SPACING_M", "40.0")) +# 블록 안에서 화살표를 낼 수 있는 셀이 이 비율 미만이면 건너뛴다(가장자리 조각 방지). +DRAINAGE_ARROW_MIN_COVERAGE = float(os.getenv("DRAINAGE_ARROW_MIN_COVERAGE", "0.5")) +# 방향 일치도 하한(원형 평균 결과 길이 0~1). 블록 안 방향이 제각각이면 평균이 무의미하므로 버린다. +DRAINAGE_ARROW_MIN_AGREEMENT = float(os.getenv("DRAINAGE_ARROW_MIN_AGREEMENT", "0.7")) +# 단계별 검증 산출물은 같은 폴더에 `{번호}_{단계}.geojson` + `manifest.json`으로 쌓인다. +# 파일명 규칙은 B05_wf2_Route_Engine_Watershed_Export.STAGES가 유일한 정의처다. + + # ───────────────────────────────────────────────────────────────────────── # 5-4. 종횡단 생성 파라미터 (B06 WF3) # ───────────────────────────────────────────────────────────────────────── @@ -435,6 +516,15 @@ STORAGE_BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "sto MAP_SHEETS_DIRNAME = "map_sheets" MAP_SHEETS_INDEX_FILENAME = "map_sheets_index.json" +# 배경 지도(위성·하이브리드·백지도) 확보 범위 = 계획노선이 걸치는 **기준 도엽**의 도곽. +# 1:5,000 도엽 1매는 약 2.2km × 2.3km다. 주변 도엽은 받지 않는다(2026-08-01 사용자 지시). +# +# 도엽 1매를 zoom 18로 받으면 한 변이 19타일(4,864px)이라 파일이 지나치게 커진다. +# 아래 한도 안에 들어오는 가장 선명한 zoom을 자동으로 고른다(브이월드 타일 눈금 그대로). +SURFACE_MAP_MAX_TILES_PER_SIDE = 16 +SURFACE_MAP_MAX_ZOOM = 18 +SURFACE_MAP_MIN_ZOOM = 14 + # 브이월드 지도서비스 도엽 자동 다운로드 — 세션 만료 시 id/pw로 자동 재로그인 (.env) VWORLD_LOGIN_ID = os.getenv("VWORLD_LOGIN_ID", "") VWORLD_LOGIN_PW = os.getenv("VWORLD_LOGIN_PW", "") diff --git a/main.py b/main.py index e32c62ea..0f491da1 100644 --- a/main.py +++ b/main.py @@ -31,7 +31,9 @@ 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 from B07_wf4_DesignDetail.B07_wf4_DesignDetail_Router import router as b07_design_router from common_util.common_util_auth import require_company, verify_session @@ -272,8 +274,10 @@ 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) app.include_router(b06_section_router, dependencies=protected_with_company) app.include_router(b07_design_router, dependencies=protected_with_company) diff --git a/pyproject.toml b/pyproject.toml index e3b1d9df..831e3b93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,8 @@ [tool.ruff] target-version = "py313" line-length = 100 +# 폐기 엔진 보관 폴더는 이동 당시 원본 그대로 두기로 했으므로 린트/포맷 대상에서 뺀다. +extend-exclude = ["B05_wf2_Route/_legacy_watershed"] [tool.ruff.lint] select = ["E", "F", "I"] diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 952e6a17..253e8b66 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -527,13 +527,13 @@ export const ui_locales = { /* --- B03_FileInput 파일 입력 --- */ B03_File_Title: ["파일 입력", "File Input"], B03_File_Subtitle: [ - "지형·포인트클라우드·도면 파일을 업로드하세요.", - "Upload terrain, point cloud, and drawing files.", + "필수 계획노선과 지형·포인트클라우드 파일을 업로드하세요.", + "Upload the required planned route, terrain, and point cloud files.", ], B03_File_Select_Label: ["입력 파일 선택", "Select input files"], B03_File_Select_Hint: [ - "LAS/LAZ 1개를 포함해 관련 PRJ, TFW, TIF 또는 도면 파일을 선택하세요.", - "Select exactly one LAS/LAZ file with related PRJ, TFW, TIF, or drawing files.", + "계획노선 CSV, LAS/LAZ 1개, PRJ, TFW를 선택하세요. TIF는 선택 사항입니다.", + "Select a planned-route CSV, one LAS/LAZ, PRJ, and TFW. TIF is optional.", ], B03_File_Selected_Title: ["선택한 파일", "Selected files"], B03_File_Selected_Empty: ["선택한 파일이 없습니다.", "No files selected."], @@ -566,6 +566,9 @@ export const ui_locales = { B03_File_Result_Path: ["저장 경로", "Stored path"], B03_File_Group_Required: ["필수 파일", "Required files"], B03_File_Group_Optional: ["선택 파일", "Optional files"], + B03_File_Group_Route: ["원청 계획노선 (필수)", "Client Planned Route (Required)"], + B03_File_Group_Terrain: ["지형 분석자료", "Terrain Analysis Files"], + B03_File_Slot_PlannedRoute: ["계획노선 좌표", "Planned Route Coordinates"], B03_File_Slot_PointCloud: ["포인트클라우드", "Point Cloud"], B03_File_Slot_Projection: ["좌표계 정의", "Projection"], B03_File_Slot_RasterCoord: ["래스터 좌표", "Raster Coord 1"], @@ -578,8 +581,8 @@ export const ui_locales = { "A file for this slot is already selected.", ], B03_File_Error_RequiredSlots: [ - "필수 파일(LAS/LAZ, PRJ, TFW)을 모두 선택하세요.", - "Select all required files: LAS/LAZ, PRJ, and TFW.", + "필수 파일(계획노선 CSV, LAS/LAZ, PRJ, TFW)을 모두 선택하세요.", + "Select all required files: planned-route CSV, LAS/LAZ, PRJ, and TFW.", ], B03_File_Error_SlotType: [ "선택한 파일 유형이 이 카드와 맞지 않습니다.", @@ -659,7 +662,7 @@ export const ui_locales = { B04_Surface_Map_Contour: ["등고선", "Contour lines"], B04_Surface_Map_SheetContour: ["도엽 등고선", "Sheet contours"], B04_Surface_Map_ContourLabel: ["등고 라벨", "Contour labels"], - B04_Surface_Map_SheetStream: ["세류(하천중심선)", "Stream centerline"], + B04_Surface_Map_SheetStream: ["세류(전체)", "Streams (all)"], B04_Surface_Map_SheetElevPoint: ["표고점", "Spot elevation"], B04_Surface_Map_SheetCutFill: ["성절토", "Cut/fill slope"], B04_Surface_Map_SheetWall: ["옹벽석축", "Retaining wall"], @@ -985,6 +988,24 @@ export const ui_locales = { "결재·문서 생성 상태를 확인하고 결과물을 내려받으세요.", "Check payment and document status, and download results.", ], + // 자료 준비 화면 (대시보드 → 작업 화면 진입 시 3D·등고선 선적재) + B11_Loading_Title: ["자료 준비 중", "Preparing data"], + B11_Loading_Subtitle: ["잠시만 기다려 주세요.", "This will take a moment."], + B11_Loading_Message: [ + "작업 화면에서 바로 쓸 수 있도록 3D 지표면과 등고선을 준비합니다.", + "Loading the 3D surface and contours so the workspace opens instantly.", + ], + B11_Loading_Start: ["자료를 준비하는 중…", "Preparing data…"], + B11_Loading_NoProject: [ + "프로젝트가 선택되지 않았습니다. 대시보드에서 프로젝트를 먼저 고르세요.", + "No project selected. Choose a project on the dashboard first.", + ], + B11_Loading_Failed: [ + "자료를 준비하지 못했습니다. 분석 결과나 저장 경로에 문제가 있을 수 있습니다. 담당자에게 연락해 주세요.", + "Could not prepare the data. The analysis result or storage path may be broken. Please contact support.", + ], + B11_Loading_Btn_Continue: ["그래도 화면으로 이동", "Continue anyway"], + B11_Loading_Btn_Dashboard: ["대시보드로", "Back to dashboard"], B11_Status_Flow_Title: ["결재 진행 상태", "Payment Progress"], B11_Status_Step_Request: ["발행 요청", "Invoice Requested"], B11_Status_Step_Issue: ["세금계산서 발행", "Invoice Issued"], diff --git a/ui_template/ui_template_progress.css b/ui_template/ui_template_progress.css new file mode 100644 index 00000000..9deb740c --- /dev/null +++ b/ui_template/ui_template_progress.css @@ -0,0 +1,88 @@ +/* 공통 프로그레스 서클 — 뷰포트 위에 얹는 원형 진행 표시. + 테두리 두께·색은 공통 로딩 스피너(.ui-spinner)와 같게 맞추고, 가운데에 진행률만 더한다. */ +.ui-progress-circle { + --ui-progress-size: 72px; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-8); + pointer-events: none; + user-select: none; +} + +/* 컨테이너 정중앙 오버레이 — 3D 뷰포트·지도·그래프 어디에나 같은 방식으로 얹는다. + 컨테이너에 position: relative 가 있어야 한다. */ +.ui-progress-circle--overlay { + position: absolute; + z-index: 1; + inset: 0; + justify-content: center; +} + +.ui-progress-circle__dial { + position: relative; + width: var(--ui-progress-size); + height: var(--ui-progress-size); +} + +.ui-progress-circle__svg { + width: 100%; + height: 100%; + /* 12시 방향에서 시계 방향으로 채운다. */ + transform: rotate(-90deg); +} + +.ui-progress-circle__track { + fill: none; + stroke: var(--color-mist-violet); + stroke-width: 8; +} + +.ui-progress-circle__bar { + fill: none; + stroke: var(--color-royal-amethyst); + stroke-width: 8; + stroke-linecap: round; + transition: stroke-dashoffset var(--transition-base); +} + +/* 진행률을 모르는 구간 — 호 하나를 계속 돌린다. */ +.ui-progress-circle.is-indeterminate .ui-progress-circle__svg { + animation: ui-progress-spin 1s linear infinite; +} + +@keyframes ui-progress-spin { + from { + transform: rotate(-90deg); + } + to { + transform: rotate(270deg); + } +} + +.ui-progress-circle__percent { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-body); + font-family: var(--font-body); + font-size: var(--text-body-sm); + font-weight: var(--font-weight-semibold); +} + +.ui-progress-circle__label { + max-width: 22ch; + padding: var(--spacing-4) var(--spacing-8); + border-radius: var(--radius-inputs); + background: color-mix(in srgb, var(--color-surface-raised) 82%, transparent); + color: var(--color-text-secondary); + font-size: var(--text-caption); + text-align: center; + word-break: keep-all; +} + +.ui-progress-circle__label:empty { + display: none; +} diff --git a/ui_template/ui_template_progress.ts b/ui_template/ui_template_progress.ts new file mode 100644 index 00000000..00c12cfb --- /dev/null +++ b/ui_template/ui_template_progress.ts @@ -0,0 +1,91 @@ +import "./ui_template_progress.css"; + +/* ============================================================================= + * ui_template_progress.ts + * 공통 프로그레스 서클 (원형 진행 표시) + * + * 뷰포트 위에 얹어 "지금 무엇을 얼마나 불러왔는지"를 보여 주는 용도. + * 진행률을 모르는 구간은 set()에 ratio를 주지 않으면 회전 애니메이션으로 표시한다. + * 색상/치수는 theme.css 변수만 사용한다. + * ========================================================================== */ + +const SVG_NS = "http://www.w3.org/2000/svg"; +/** viewBox 기준 반지름 — 실제 크기는 CSS(--ui-progress-size)로 정한다. */ +const RADIUS = 42; +const CIRCUMFERENCE = 2 * Math.PI * RADIUS; + +export interface ProgressCircleOptions { + /** 지름(px). 기본 72(공통 로딩 스피너와 같은 무게감). */ + size?: number; + /** 서클 아래 안내 문구. */ + label?: string; + /** 컨테이너 정중앙에 띄우는 오버레이로 만든다(컨테이너는 position: relative 여야 한다). */ + overlay?: boolean; +} + +export interface ProgressCircleHandle { + root: HTMLDivElement; + /** 진행률(0~1)과 문구 갱신. ratio가 null이면 진행률 미상(회전 표시). */ + set: (ratio: number | null, label?: string) => void; + /** 화면에서 제거. */ + remove: () => void; +} + +export function createProgressCircle(options: ProgressCircleOptions = {}): ProgressCircleHandle { + const root = document.createElement("div"); + root.className = "ui-progress-circle" + (options.overlay ? " ui-progress-circle--overlay" : ""); + root.setAttribute("role", "status"); + root.setAttribute("aria-live", "polite"); + if (options.size) root.style.setProperty("--ui-progress-size", `${options.size}px`); + + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("class", "ui-progress-circle__svg"); + svg.setAttribute("viewBox", "0 0 100 100"); + const track = document.createElementNS(SVG_NS, "circle"); + track.setAttribute("class", "ui-progress-circle__track"); + track.setAttribute("cx", "50"); + track.setAttribute("cy", "50"); + track.setAttribute("r", String(RADIUS)); + const bar = document.createElementNS(SVG_NS, "circle"); + bar.setAttribute("class", "ui-progress-circle__bar"); + bar.setAttribute("cx", "50"); + bar.setAttribute("cy", "50"); + bar.setAttribute("r", String(RADIUS)); + bar.setAttribute("stroke-dasharray", String(CIRCUMFERENCE)); + bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE)); + svg.append(track, bar); + + const percent = document.createElement("span"); + percent.className = "ui-progress-circle__percent"; + const label = document.createElement("span"); + label.className = "ui-progress-circle__label"; + label.textContent = options.label ?? ""; + + const dial = document.createElement("div"); + dial.className = "ui-progress-circle__dial"; + dial.append(svg, percent); + root.append(dial, label); + + function set(ratio: number | null, nextLabel?: string): void { + if (nextLabel !== undefined) label.textContent = nextLabel; + if (ratio === null) { + // 진행률 미상 — 4분의 1 호를 돌려 "돌아가는 중"만 알린다. + root.classList.add("is-indeterminate"); + bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * 0.75)); + percent.textContent = ""; + return; + } + const clamped = Math.min(1, Math.max(0, ratio)); + root.classList.remove("is-indeterminate"); + bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * (1 - clamped))); + percent.textContent = `${Math.round(clamped * 100)}%`; + } + + set(0, options.label); + + return { + root, + set, + remove: () => root.remove(), + }; +}