feat(B04): 상세 배수유역 계산 — 관 매설 지점 편집 + 세부유역 분할

관 매설 지점을 기준으로 세부 배수유역을 나누는 기능을 B04 2D 지도에 추가한다.
경계는 B04 전처리 격자(03_road_routing.npz)의 road_slot — 1m 셀마다 물이 도달하는
도로 셀 — 을 담당 관으로 라벨링해 그 경계로 잡는다. 종단 Z는 경계를 긋지 않고
"도로 셀이 어느 관으로 흐르는가"만 정한다.

공용 승격 (B04 관리자 화면과 B05 사용자 화면이 같은 결과를 내야 함)
- common_util_drainage_detail.py: 관 보충(9)·세부유역 분할(10) 알고리즘
- common_util_drainage_context.py: 노선·종단 Z·좌표계 입력 준비
- common_util_drainage_pipes.py: 관 지점 정본 저장소(edits/pipe_points.json)
- common_util_route_profile.py: 종단 Z 해석기(계획고 > 경로 정점 > 지표면 > CSV)
- common_util_surface_sampler.py: B05 종횡단 sampler 이동
- B05 _prepare()의 노선 소스를 원청 계획노선 CSV로 정정(B04 격자와 누가거리 정합)

B04 신규 API
- GET  /{project_id}/drainage/pipe-points   저장분 조회(없으면 자동 생성)
- POST /{project_id}/drainage/detail-basins 편집 중 목록으로 재분할(저장 안 함)
- PUT  /{project_id}/drainage/pipe-points   모델 확정 시 관 지점·세부유역 커밋

B04 화면
- 관 마커 기본/자동/수동 색 구분, 계획선 스냅 드래그 이동
- 계획선 우클릭 "관 매설 추가" / 마커 우클릭 "관 매설 삭제"
- 표시 토글 2그룹(관 매설 / 세부 유역)을 유입 집중점과 분리
- "상세유역 분석" 버튼을 눌렀을 때만 재계산

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 21:48:28 +09:00
co-authored by Claude Opus 5
parent 9fb24c1ce6
commit 07c4a7897c
27 changed files with 2077 additions and 716 deletions
@@ -431,3 +431,72 @@ export async function fetchRoadInflow(
{ method: "GET" },
);
}
/* ── 상세 배수유역(관 매설 지점 + 세부유역 분할) ─────────────────────────── */
/** 관이 그 자리에 있는 이유. 백엔드 `common_util_drainage_pipes`가 정의처다. */
export type PipeSource = "stream" | "spacing" | "user";
export interface DetailPipePoint {
chainage_m: number;
lonlat: [number, number];
source: PipeSource;
}
export interface DetailBasin {
index: number;
chainage_m: number;
outlet_lonlat: [number, number];
polygon_lonlat: Array<[number, number]>;
area_m2: number;
relief_m: number;
flow_length_m: number;
/** 관경 수식 미확정 — null이면 "미정"으로 표기한다. */
pipe_diameter_mm: number | null;
}
export interface DetailBasinResponse {
status: string;
project_id: string;
/** 종단 Z 출처(design_profile / route_points / surface / csv). */
z_source: string;
route_length_m: number;
max_spacing_m: number;
min_spacing_m: number;
/** 응답의 관 목록이 저장분에서 온 것인지. */
saved: boolean;
pipe_points: DetailPipePoint[];
basins: DetailBasin[];
pipe_count: number;
}
/** 저장된 관 매설 지점과 그 세부유역. 저장분이 없으면 백엔드가 자동 생성해 돌려준다. */
export async function fetchDetailPipePoints(projectId: string): Promise<DetailBasinResponse> {
return requestJson<DetailBasinResponse>(`/projects/${projectId}/drainage/pipe-points`, {
method: "GET",
});
}
/** 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다).
*
* `points`를 비우면 저장분을 무시하고 기본 관 + 자동 보충으로 되돌린다. */
export async function computeDetailBasins(
projectId: string,
points: Array<{ chainage_m: number; source: PipeSource }>,
): Promise<DetailBasinResponse> {
return requestJson<DetailBasinResponse>(`/projects/${projectId}/drainage/detail-basins`, {
method: "POST",
body: JSON.stringify({ points }),
});
}
/** 관 매설 지점을 정본으로 확정하고 세부유역 산출물까지 남긴다(모델 확정 시점). */
export async function saveDetailPipePoints(
projectId: string,
points: Array<{ chainage_m: number; source: PipeSource }>,
): Promise<DetailBasinResponse> {
return requestJson<DetailBasinResponse>(`/projects/${projectId}/drainage/pipe-points`, {
method: "PUT",
body: JSON.stringify({ points }),
});
}
@@ -0,0 +1,225 @@
"""상세 배수유역 API 라우터 (B04 — 관리자 검토용).
격자 해석은 하지 않는다. 배수유역 분석이 저장해 둔 `03_road_routing` 산출물을 읽어
· 기본 관(도로 × 상류 세류선 교차점) + 최대 간격 자동 보충으로 관 목록을 만들고
· 계획노선 종단 Z로 노면 물이 어느 관으로 가는지 정해 세부유역을 나눈다
계산 알고리즘은 B05 사용자 화면과 공용이다(`common_util_drainage_detail`) — 같은 이름의
버튼은 같은 결과를 내야 하기 때문이다(2026-08-01 사용자 지시).
편집분(관 지점)은 "이 모델 확정" 시점에만 파일로 남는다. 그 전까지는 화면 메모리에만 있고,
확정 없이 나가면 저장된 값으로 되돌아온다.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from uuid import UUID
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Export import drainage_dir
from common_util.common_util_drainage_context import DrainageContext, load_drainage_context
from common_util.common_util_drainage_detail import DrainageDetail, build_detail
from common_util.common_util_drainage_pipes import (
PIPE_SOURCE_USER,
PipePoint,
load_pipe_points,
parse_pipe_points,
route_signature,
save_detail_basins,
save_pipe_points,
)
from common_util.common_util_route_geometry import StructureCandidate
from config.config_system import DRAINAGE_PIPE_MAX_SPACING_M, DRAINAGE_PIPE_MIN_SPACING_M
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Drainage Basins"])
_NO_ANALYSIS = "배수유역 분석 결과가 없습니다. [유역 분석]을 먼저 실행하세요."
def _build(
stored_path: str, context: DrainageContext, points: list[PipePoint] | None
) -> tuple[DrainageDetail | None, list[PipePoint]]:
"""세부유역을 계산하고, 그 결과에 쓰인 관 목록을 함께 돌려준다.
`points`가 없으면 B04 기본 관에 최대 간격 규칙으로 자동 보충한 목록이 만들어진다.
있으면 그 자리를 그대로 쓰되, 관이 왜 거기 있는지(기본/자동/수동)는 넘겨받은 목록의
표시를 유지한다 — 계산기는 확정 좌표만 보므로 그대로 두면 전부 "수동"이 된다.
"""
directory = drainage_dir(stored_path)
chainages = [point.chainage_m for point in points] if points else None
detail = build_detail(directory, context.vertices, chainages)
if detail is None:
return None, []
if points:
_retag(detail.pipes, points)
return detail, [
PipePoint(chainage_m=pipe.chainage_m, source=pipe.reason) for pipe in detail.pipes
]
def _retag(pipes: list[StructureCandidate], points: list[PipePoint]) -> None:
"""확정 좌표로 되돌아온 관에 원래의 생성 사유를 다시 붙인다."""
by_chainage = {round(point.chainage_m, 2): point.source for point in points}
for pipe in pipes:
source = by_chainage.get(round(pipe.chainage_m, 2))
if source is None and points:
nearest = min(points, key=lambda item: abs(item.chainage_m - pipe.chainage_m))
source = nearest.source
pipe.reason = source or PIPE_SOURCE_USER
def _payload(
project_id: UUID,
context: DrainageContext,
detail: DrainageDetail,
points: list[PipePoint],
saved: bool,
) -> dict[str, Any]:
"""관 목록과 세부유역을 화면 좌표(WGS84)로 정리한다."""
to_lonlat = context.to_lonlat
return {
"status": "success",
"project_id": str(project_id),
# 종단 Z를 어디서 가져왔는지 — 관 담당 구간이 갈리는 근거라 화면에서 확인 가능해야 한다.
"z_source": context.z_source,
"route_length_m": round(context.vertices[-1].chainage_m, 2),
"max_spacing_m": DRAINAGE_PIPE_MAX_SPACING_M,
"min_spacing_m": DRAINAGE_PIPE_MIN_SPACING_M,
"saved": saved,
"pipe_points": [
{
"chainage_m": round(pipe.chainage_m, 2),
"lonlat": list(to_lonlat(pipe.x, pipe.y)),
"source": pipe.reason,
}
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
],
"pipe_count": len(points),
}
def _basin_features(
context: DrainageContext, detail: DrainageDetail, points: list[PipePoint]
) -> list[dict[str, Any]]:
"""세부유역을 저장용 GeoJSON 피처로 바꾼다(관 지점도 같은 파일에 함께 남긴다)."""
to_lonlat = context.to_lonlat
features: list[dict[str, Any]] = []
for basin in detail.basins:
ring = [list(to_lonlat(x, y)) for x, y in basin.boundary_xy]
if len(ring) < 4:
continue
features.append(
{
"type": "Feature",
"properties": {
"kind": "detail_basin",
"index": basin.index,
"chainage_m": round(basin.chainage_m, 2),
"area_m2": round(basin.area_m2, 1),
"relief_m": round(basin.relief_m, 2),
"flow_length_m": round(basin.flow_length_m, 1),
"pipe_diameter_mm": basin.pipe_diameter_mm,
},
"geometry": {"type": "Polygon", "coordinates": [ring]},
}
)
for pipe, point in zip(detail.pipes, points):
features.append(
{
"type": "Feature",
"properties": {
"kind": "pipe",
"chainage_m": round(point.chainage_m, 2),
"source": point.source,
},
"geometry": {"type": "Point", "coordinates": list(to_lonlat(pipe.x, pipe.y))},
}
)
return features
async def _resolve(
project_id: UUID, payload: dict[str, Any] | None, *, use_stored: bool
) -> tuple[DrainageContext, DrainageDetail, list[PipePoint], bool] | JSONResponse:
"""요청 본문 → 저장분 → 자동 생성 순으로 관 목록을 정하고 세부유역까지 계산한다."""
context, reason = await load_drainage_context(project_id)
if context is None:
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
requested = parse_pipe_points((payload or {}).get("points"))
signature = route_signature(context.vertices)
stored = load_pipe_points(context.stored_path, signature) if use_stored else None
points = requested or stored or None
result = await asyncio.to_thread(_build, context.stored_path, context, points)
detail, resolved = result
if detail is None:
return JSONResponse(status_code=404, content={"status": "error", "message": _NO_ANALYSIS})
return context, detail, resolved, bool(stored) and not requested
@router.get("/{project_id}/drainage/pipe-points", response_model=None)
async def get_pipe_points(project_id: UUID) -> dict[str, Any] | JSONResponse:
"""저장된 관 지점과 그 세부유역을 돌려준다. 저장분이 없으면 자동 생성한 목록."""
resolved = await _resolve(project_id, None, use_stored=True)
if isinstance(resolved, JSONResponse):
return resolved
context, detail, points, saved = resolved
return _payload(project_id, context, detail, points, saved)
@router.post("/{project_id}/drainage/detail-basins", response_model=None)
async def post_detail_basins(
project_id: UUID, payload: dict[str, Any] | None = None
) -> dict[str, Any] | JSONResponse:
"""화면에서 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다).
`points`를 비우고 부르면 저장분을 무시하고 기본 관 + 자동 보충으로 되돌린다 —
노선이 바뀌어 전체 분석을 다시 돌린 직후의 경로다.
"""
resolved = await _resolve(project_id, payload, use_stored=False)
if isinstance(resolved, JSONResponse):
return resolved
context, detail, points, _ = resolved
return _payload(project_id, context, detail, points, saved=False)
@router.put("/{project_id}/drainage/pipe-points", response_model=None)
async def put_pipe_points(
project_id: UUID, payload: dict[str, Any] | None = None
) -> dict[str, Any] | JSONResponse:
"""관 지점을 정본으로 확정하고 세부유역 산출물까지 함께 남긴다(모델 확정 시점)."""
resolved = await _resolve(project_id, payload, use_stored=True)
if isinstance(resolved, JSONResponse):
return resolved
context, detail, points, _ = resolved
signature = route_signature(context.vertices)
saved = await asyncio.to_thread(save_pipe_points, context.stored_path, signature, points)
await asyncio.to_thread(
save_detail_basins, context.stored_path, _basin_features(context, detail, points)
)
result = _payload(project_id, context, detail, points, saved=True)
result["saved_count"] = saved
return result
@@ -0,0 +1,438 @@
/* =============================================================================
* 상세 배수유역 오버레이 (B04 2D 지도 — 관리자 검토용)
*
* 계획선 위 **관 매설 지점**을 편집하고, 그 관마다 물이 모이는 **세부유역**을 겹쳐 그린다.
*
* · 기본 관 = 도로 × 상류 세류선 교차점 (백엔드 분석 산출물)
* · 자동 보충 = 관 최대 간격을 넘는 구간에 최소 개수로 채운 자리
* · 수동 = 계획선 위에서 우클릭해 넣었거나, 끌어서 옮긴 관
*
* 경계는 여기서 긋지 않는다. 백엔드가 1m 격자 셀마다 "이 셀 물이 어느 도로 셀로 가는가"를
* 이미 풀어 두었고, 도로 셀은 종단 내리막을 따라 담당 관으로 묶인다. 그래서 같은 관으로
* 묶인 셀 덩어리의 바깥선이 곧 세부유역 경계다 — 화면은 그 폴리곤을 받아 칠하기만 한다.
*
* 재계산은 **버튼을 눌렀을 때만** 돈다(2026-08-01 사용자 지시). 편집 중에는 마커만 움직이고,
* 확정 전에 화면을 떠나면 저장된 값으로 되돌아온다.
* ========================================================================== */
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { themeColor } from "@ui/ui_template_palette";
import {
computeDetailBasins,
fetchDetailPipePoints,
saveDetailPipePoints,
type DetailBasin,
type PipeSource,
type VWorldMeta,
} from "./B04_wf1_Surface_Api_Fetch";
import {
haloColor,
lonLatToScreen,
metricToScreen,
type Normalizer,
type ViewState,
} from "./B04_wf1_Surface_UI_MapRender";
import {
nearestChainage,
pointAtChainage,
resampleRoute,
type RoutePoint,
} from "./B04_wf1_Surface_UI_RouteSamples";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 관 마커 반지름(px)과 잡기 여유(px). 유입 집중점 마커보다 조금 크게 둬서 구분된다. */
const PIPE_RADIUS = 8;
const PIPE_HIT_SLACK = 5;
/** 계획선에서 이보다 멀면 "노선 위 우클릭"으로 보지 않는다(px). */
const ROUTE_HIT_PX = 14;
/** 관 생성 사유별 색. 정의처는 `ui_template_theme.css`. */
const PIPE_COLORS: Record<PipeSource, [token: string, fallback: string]> = {
stream: ["--map-pipe-marker", "#f97316"],
spacing: ["--map-pipe-auto", "#0ea5e9"],
user: ["--map-pipe-user", "#16a34a"],
};
/** 세부유역 채움 — 이웃끼리 구분만 되면 되므로 색상환을 균등 분할해 돌려 쓴다. */
function basinColor(index: number, alpha: number): string {
const hue = (index * 137.508) % 360; // 황금각 — 인접 유역이 비슷한 색으로 붙지 않는다
return `hsla(${hue.toFixed(0)}, 70%, 55%, ${alpha})`;
}
interface PipeMarker {
chainage: number;
source: PipeSource;
}
export interface DetailBasinOverlay {
/** 세부유역 재계산 버튼. */
button: HTMLButtonElement;
/** 표시 토글 — 관 매설 마커 / 세부 유역. 유입 집중점과 겹쳐 볼 수 있게 따로 둔다. */
partButtons: HTMLButtonElement[];
statusElement: HTMLElement;
/** 우클릭 메뉴. 지도 뷰포트에 얹는다(뷰포트 기준 절대 위치). */
menuElement: HTMLElement;
setProject: (projectId: string) => void;
/** 계획선(사업지 좌표계 m)과 배경지도 메타. 둘이 있어야 화면 좌표를 낼 수 있다. */
setRoute: (points: ReadonlyArray<RoutePoint>, meta: VWorldMeta | null) => void;
clear: () => void;
/** 마커를 잡았으면 true(지도 팬 대신 마커 끌기로 넘어간다). */
handlePointerDown: (view: ViewState, x: number, y: number) => boolean;
handlePointerMove: (view: ViewState, x: number, y: number) => boolean;
handlePointerUp: () => boolean;
/** 우클릭 메뉴를 띄웠으면 true. */
handleContextMenu: (view: ViewState, x: number, y: number) => boolean;
/** 모델 확정 시점에 관 지점과 세부유역을 영구저장한다. */
commit: () => Promise<number>;
draw: (context: CanvasRenderingContext2D, normalizer: Normalizer | null, view: ViewState) => void;
dispose: () => void;
}
export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverlay {
let projectId: string | null = null;
let meta: VWorldMeta | null = null;
let samples: RoutePoint[] = [];
let pipes: PipeMarker[] = [];
let basins: DetailBasin[] = [];
let zSource = "";
let minSpacing = 20;
let busy = false;
let dirty = false;
/** 끌고 있는 마커 번호. null이면 잡은 것이 없다. */
let dragging: number | null = null;
let requestSequence = 0;
const statusElement = document.createElement("span");
statusElement.className = "b04-map__watershed-status";
statusElement.hidden = true;
const menuElement = document.createElement("div");
menuElement.className = "b04-map__context-menu";
menuElement.hidden = true;
const button = document.createElement("button");
button.type = "button";
button.className = "b04-map__layer-button b04-map__layer-button--gis";
button.textContent = L("B04_Surface_Basin_Btn");
button.style.setProperty("--b04-layer-color", themeColor("--map-pipe-user", "#16a34a"));
button.title = L("B04_Surface_Basin_Btn_Tip");
const shownParts = { pipes: true, basins: true };
const partButtons = (
[
["pipes", "B04_Surface_Basin_Part_Pipes", PIPE_COLORS.stream],
["basins", "B04_Surface_Basin_Part_Basins", ["--map-pipe-user", "#16a34a"]],
] as const
).map(([key, labelKey, token]) => {
const element = document.createElement("button");
element.type = "button";
element.className = "b04-map__layer-button b04-map__layer-button--gis is-active";
element.textContent = L(labelKey);
element.style.setProperty("--b04-layer-color", themeColor(token[0], token[1]));
element.setAttribute("aria-pressed", "true");
element.addEventListener("click", () => {
shownParts[key] = !shownParts[key];
element.classList.toggle("is-active", shownParts[key]);
element.setAttribute("aria-pressed", String(shownParts[key]));
onChange();
});
return element;
});
function say(text: string): void {
statusElement.textContent = text;
statusElement.hidden = text === "";
}
function summary(): string {
const count = (source: PipeSource): number =>
pipes.filter((pipe) => pipe.source === source).length;
const line = L("B04_Surface_Basin_Summary")
.replace("{pipes}", String(pipes.length))
.replace("{stream}", String(count("stream")))
.replace("{spacing}", String(count("spacing")))
.replace("{user}", String(count("user")))
.replace("{basins}", String(basins.length))
.replace("{source}", zSource || "-");
return dirty ? `${line} · ${L("B04_Surface_Basin_Edited")}` : line;
}
function pipeScreen(view: ViewState, chainage: number): [number, number] | null {
if (!meta) return null;
const point = pointAtChainage(samples, chainage);
return point ? metricToScreen(meta, view, point.x, point.y) : null;
}
/** 마커 히트 판정. 가장 가까운 것 하나. */
function hitPipe(view: ViewState, x: number, y: number): number | null {
let hit: number | null = null;
let best = Number.POSITIVE_INFINITY;
pipes.forEach((pipe, index) => {
const screen = pipeScreen(view, pipe.chainage);
if (!screen) return;
const distance = Math.hypot(screen[0] - x, screen[1] - y);
if (distance <= PIPE_RADIUS + PIPE_HIT_SLACK && distance < best) {
hit = index;
best = distance;
}
});
return hit;
}
/** 화면 좌표를 계획선 위 누가거리로 스냅한다. 계획선에서 멀면 null. */
function snap(view: ViewState, x: number, y: number): number | null {
if (!meta) return null;
const found = nearestChainage(
samples,
(point) => metricToScreen(meta as VWorldMeta, view, point.x, point.y),
x,
y,
);
return found && found.distance <= ROUTE_HIT_PX ? found.chainage : null;
}
/** 관끼리 최소 간격을 지키는지 본다(자기 자신은 제외). */
function tooClose(chainage: number, exceptIndex: number | null): boolean {
return pipes.some(
(pipe, index) =>
index !== exceptIndex && Math.abs(pipe.chainage - chainage) < minSpacing - 1e-6,
);
}
function markEdited(): void {
dirty = true;
say(summary());
onChange();
}
function closeMenu(): void {
menuElement.hidden = true;
menuElement.replaceChildren();
}
function openMenu(x: number, y: number, items: Array<[string, () => void]>): void {
menuElement.replaceChildren();
items.forEach(([label, action]) => {
const entry = document.createElement("button");
entry.type = "button";
entry.className = "b04-map__context-menu-item";
entry.textContent = label;
entry.addEventListener("click", () => {
closeMenu();
action();
});
menuElement.append(entry);
});
menuElement.style.left = `${x}px`;
menuElement.style.top = `${y}px`;
menuElement.hidden = false;
}
/** 응답을 화면 상태로 옮긴다. */
function apply(response: Awaited<ReturnType<typeof fetchDetailPipePoints>>): void {
pipes = response.pipe_points.map((point) => ({
chainage: point.chainage_m,
source: point.source,
}));
basins = response.basins;
zSource = response.z_source;
minSpacing = response.min_spacing_m;
dirty = false;
say(summary());
}
/** 세부유역을 다시 나눈다. 편집 중인 관 목록을 그대로 보낸다. */
async function recompute(): Promise<void> {
if (!projectId || busy) return;
busy = true;
button.disabled = true;
button.textContent = L("B04_Surface_Basin_Btn_Busy");
const sequence = ++requestSequence;
try {
const response = await computeDetailBasins(
projectId,
pipes.map((pipe) => ({ chainage_m: pipe.chainage, source: pipe.source })),
);
if (sequence !== requestSequence) return;
apply(response);
} catch (error) {
if (sequence !== requestSequence) return;
say(error instanceof Error ? error.message : L("B04_Surface_Basin_Failed"));
} finally {
busy = false;
button.disabled = false;
button.textContent = L("B04_Surface_Basin_Btn");
onChange();
}
}
/** 저장분(없으면 자동 생성분)을 불러온다. 지도를 열 때 조용히 돈다. */
async function loadSaved(): Promise<void> {
if (!projectId) return;
const sequence = ++requestSequence;
try {
const response = await fetchDetailPipePoints(projectId);
if (sequence !== requestSequence) return;
apply(response);
} catch {
if (sequence !== requestSequence) return;
// 배수유역 분석 전이면 여기서 실패하는 것이 정상이다 — 조용히 비워 둔다.
pipes = [];
basins = [];
say("");
}
onChange();
}
button.addEventListener("click", () => {
void recompute();
});
return {
button,
partButtons,
statusElement,
menuElement,
setProject(next) {
projectId = next;
pipes = [];
basins = [];
dirty = false;
say("");
void loadSaved();
},
setRoute(points, nextMeta) {
meta = nextMeta;
samples = resampleRoute(points);
},
clear() {
pipes = [];
basins = [];
zSource = "";
dirty = false;
dragging = null;
requestSequence += 1;
closeMenu();
say("");
},
handlePointerDown(view, x, y) {
closeMenu();
if (!shownParts.pipes || pipes.length === 0) return false;
const hit = hitPipe(view, x, y);
if (hit === null) return false;
dragging = hit;
return true;
},
handlePointerMove(view, x, y) {
if (dragging === null) return false;
const chainage = snap(view, x, y);
// 계획선에서 벗어난 위치는 무시한다 — 관은 늘 노선 위에 있어야 한다.
if (chainage === null || tooClose(chainage, dragging)) return true;
pipes[dragging] = { chainage, source: "user" };
pipes.sort((left, right) => left.chainage - right.chainage);
dragging = pipes.findIndex((pipe) => pipe.chainage === chainage);
markEdited();
return true;
},
handlePointerUp() {
if (dragging === null) return false;
dragging = null;
return true;
},
handleContextMenu(view, x, y) {
closeMenu();
if (!shownParts.pipes) return false;
const hit = hitPipe(view, x, y);
if (hit !== null) {
openMenu(x, y, [
[
L("B04_Surface_Basin_Menu_Delete"),
() => {
pipes.splice(hit, 1);
markEdited();
},
],
]);
return true;
}
const chainage = snap(view, x, y);
if (chainage === null) return false;
openMenu(x, y, [
[
L("B04_Surface_Basin_Menu_Add"),
() => {
if (tooClose(chainage, null)) {
say(L("B04_Surface_Basin_TooClose").replace("{min}", String(minSpacing)));
onChange();
return;
}
pipes.push({ chainage, source: "user" });
pipes.sort((left, right) => left.chainage - right.chainage);
markEdited();
},
],
]);
return true;
},
async commit() {
if (!projectId) return 0;
const response = await saveDetailPipePoints(
projectId,
pipes.map((pipe) => ({ chainage_m: pipe.chainage, source: pipe.source })),
);
apply(response);
onChange();
return response.pipe_count;
},
draw(context, normalizer, view) {
if (shownParts.basins && normalizer && basins.length > 0) {
context.save();
context.lineJoin = "round";
basins.forEach((basin, index) => {
if (basin.polygon_lonlat.length < 3) return;
context.beginPath();
basin.polygon_lonlat.forEach(([lon, lat], order) => {
const [px, py] = lonLatToScreen(normalizer, view, lon, lat);
if (order === 0) context.moveTo(px, py);
else context.lineTo(px, py);
});
context.closePath();
context.fillStyle = basinColor(index, 0.22);
context.fill();
context.strokeStyle = basinColor(index, 0.95);
context.lineWidth = 1.8;
context.stroke();
});
context.restore();
}
if (!shownParts.pipes || pipes.length === 0) return;
context.save();
context.textAlign = "center";
context.textBaseline = "middle";
pipes.forEach((pipe, index) => {
const screen = pipeScreen(view, pipe.chainage);
if (!screen) return;
const [x, y] = screen;
const [token, fallback] = PIPE_COLORS[pipe.source];
context.beginPath();
context.arc(x, y, PIPE_RADIUS, 0, Math.PI * 2);
context.fillStyle = themeColor(token, fallback);
context.fill();
context.lineWidth = index === dragging ? 3 : 1.6;
context.strokeStyle = haloColor();
context.stroke();
context.font = "bold 10px sans-serif";
context.fillStyle = themeColor("--map-marker-text", "#111827");
context.fillText(String(index + 1), x, y);
});
context.restore();
},
dispose() {
requestSequence += 1;
closeMenu();
},
};
}
@@ -20,6 +20,7 @@ import {
type Normalizer,
type ViewState,
} from "./B04_wf1_Surface_UI_MapRender";
import { pointAtChainage, resampleRoute, type RoutePoint } from "./B04_wf1_Surface_UI_RouteSamples";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -43,7 +44,7 @@ const MARKER_MAX_RADIUS = 10;
/** 마커를 눌렀다고 볼 여유(px). */
const MARKER_HIT_SLACK = 4;
export type RoutePoint = { x: number; y: number };
export type { RoutePoint };
export interface FlowStrengthOverlay {
/** 지도 헤더 버튼 줄에 넣을 토글. */
@@ -131,38 +132,6 @@ export function createFlowStrengthOverlay(onChange: () => void): FlowStrengthOve
// 늦게 도착한 응답이 최신 선택을 덮어쓰지 않게 요청마다 번호를 매긴다.
let requestSequence = 0;
/** 계획선을 1m 간격으로 다시 찍는다. 강도 곡선의 인덱스와 그대로 맞물린다. */
function resample(): void {
samples = [];
if (routePoints.length < 2) return;
let carried = 0;
samples.push({ x: routePoints[0].x, y: routePoints[0].y });
for (let i = 1; i < routePoints.length; i += 1) {
const from = routePoints[i - 1];
const to = routePoints[i];
const dx = to.x - from.x;
const dy = to.y - from.y;
const length = Math.hypot(dx, dy);
if (length <= 0) continue;
let travelled = 1 - carried;
while (travelled <= length) {
samples.push({
x: from.x + (dx * travelled) / length,
y: from.y + (dy * travelled) / length,
});
travelled += 1;
}
carried = (carried + length) % 1;
}
}
/** 누가거리(m) 위치의 계획선 좌표. 범위를 벗어나면 양 끝으로 자른다. */
function pointAt(chainage: number): RoutePoint | null {
if (samples.length === 0) return null;
const index = Math.min(samples.length - 1, Math.max(0, Math.round(chainage)));
return samples[index];
}
/** 로그 스케일 정규화 — 계곡 한 점이 사면보다 수백 배라 선형으로는 못 읽는다. */
function normalize(value: number): number {
if (maxStrength <= 0 || value <= 0) return 0;
@@ -195,7 +164,7 @@ export function createFlowStrengthOverlay(onChange: () => void): FlowStrengthOve
function markerScreen(view: ViewState, chainage: number): [number, number] | null {
if (!meta) return null;
const point = pointAt(chainage);
const point = pointAtChainage(samples, chainage);
if (!point) return null;
return metricToScreen(meta, view, point.x, point.y);
}
@@ -309,7 +278,7 @@ export function createFlowStrengthOverlay(onChange: () => void): FlowStrengthOve
setRoute(points, nextMeta) {
routePoints = points;
meta = nextMeta;
resample();
samples = resampleRoute(routePoints);
},
clear() {
strength = new Float64Array(0);
@@ -0,0 +1,75 @@
/* =============================================================================
* 2D 지도 레이어 목록·기본값·색 (B04)
*
* 어떤 레이어를 다루는지, 처음에 무엇이 켜져 있는지, 무슨 색으로 그리는지만 모은다.
* 지도 뷰어 본체(`B04_wf1_Surface_UI_MapViewer.ts`)가 700줄 한계에 닿아 분리했다.
* 색 값의 정의처는 `ui_template_theme.css`(`--map-*`)이며 여기는 이름만 잇는다.
* ========================================================================== */
import { themeColor } from "@ui/ui_template_palette";
export const BACKGROUND_LAYERS = ["white", "satellite", "hybrid"] as const;
// 유수방향은 화면에 쓰지 않기로 해 목록에서 뺐다(2026-08-01 사용자 지시).
export const GIS_LAYERS = [
"지적도",
"행정구역_시군구",
"행정구역_읍면동",
"등고선",
"도엽_등고선",
"도엽_하천중심선",
"도엽_표고점",
"도엽_성절토",
"도엽_옹벽석축",
] as const;
export type BackgroundLayer = (typeof BACKGROUND_LAYERS)[number];
export type GisLayer = (typeof GIS_LAYERS)[number];
/* ── 처음 열었을 때 켜져 있을 레이어 (2026-08-01 사용자 지정) ──────────────
* 지도가 복잡해지지 않도록 실제로 자주 보는 것만 켠 채로 시작한다.
* 나머지는 버튼으로 그때그때 켠다. */
export const BACKGROUND_DEFAULT_ON: Record<BackgroundLayer, boolean> = {
white: true,
satellite: true,
hybrid: false,
};
export const GIS_DEFAULT_ON: Record<GisLayer, boolean> = {
지적도: false,
행정구역_시군구: true,
행정구역_읍면동: true,
등고선: false,
도엽_등고선: false,
도엽_하천중심선: true,
도엽_표고점: false,
도엽_성절토: false,
도엽_옹벽석축: false,
};
/** 등고 라벨(계곡선 수치) 기본 표시 여부. */
export const CONTOUR_LABEL_DEFAULT_ON = false;
/** 이만큼(px) 이하로 움직였다 뗐으면 클릭으로 본다 — 손떨림으로 선택이 안 되는 일을 막는다. */
export const CLICK_SLOP_PX = 4;
/** 레이어별 색은 `ui_template_theme.css`의 `--map-*`가 정의처다. 여기는 이름만 잇는다.
* (fallback 값은 CSS가 아직 안 붙은 첫 프레임 대비용 안전값) */
const GIS_LAYER_COLOR_TOKENS: Record<GisLayer, [name: string, fallback: string]> = {
: ["--map-cadastral", "#f97316"],
_시군구: ["--map-sigungu", "#7c3aed"],
_읍면동: ["--map-eupmyeondong", "#22c55e"],
: ["--map-contour", "#fdba74"],
_등고선: ["--map-sheet-contour", "#a5b4fc"],
_하천중심선: ["--map-sheet-stream", "#2563eb"],
_표고점: ["--map-sheet-elev-point", "#f9a8d4"],
_성절토: ["--map-sheet-cutfill", "#f43f5e"],
_옹벽석축: ["--map-sheet-wall", "#0f766e"],
};
export const gisLayerColor = (layer: GisLayer): string =>
themeColor(...GIS_LAYER_COLOR_TOKENS[layer]);
/** 등고 라벨 표기 대상 레이어와 표고 속성 키 (gpkg=CTRLN_HG, 도엽=등고수치) */
export const CONTOUR_LABEL_KEYS: Partial<Record<GisLayer, string[]>> = {
: ["CTRLN_HG"],
_등고선: ["등고수치"],
};
+84 -78
View File
@@ -1,6 +1,5 @@
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createProgressCircle } from "@ui/ui_template_progress";
import { themeColor } from "@ui/ui_template_palette";
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
import {
fetchGisGeoJson,
@@ -10,6 +9,19 @@ import {
type VWorldMeta,
} from "./B04_wf1_Surface_Api_Fetch";
import { niceScaleDistance } from "./B04_wf1_Surface_UI_Camera";
import { createDetailBasinOverlay } from "./B04_wf1_Surface_UI_Basins";
import {
BACKGROUND_DEFAULT_ON,
BACKGROUND_LAYERS,
CLICK_SLOP_PX,
CONTOUR_LABEL_DEFAULT_ON,
CONTOUR_LABEL_KEYS,
GIS_DEFAULT_ON,
GIS_LAYERS,
gisLayerColor,
type BackgroundLayer,
type GisLayer,
} from "./B04_wf1_Surface_UI_MapLayers";
import { createFlowStrengthOverlay } from "./B04_wf1_Surface_UI_FlowStrength";
import { createWatershedOverlay } from "./B04_wf1_Surface_UI_Watershed";
import {
@@ -35,74 +47,11 @@ export interface SurfaceMapViewer {
root: HTMLElement;
/** routeBounds: 계획노선 평면 범위 — 초기 화면을 도로 중심으로 맞추는 데 쓴다. */
render: (projectId: string, routeBounds?: PlanBounds | null) => void;
/** 관 매설 지점·세부유역을 영구저장한다. 저장한 관 개수를 돌려준다(모델 확정과 함께 호출). */
commitDrainage: () => Promise<number>;
dispose: () => void;
}
const BACKGROUND_LAYERS = ["white", "satellite", "hybrid"] as const;
// 유수방향은 화면에 쓰지 않기로 해 목록에서 뺐다(2026-08-01 사용자 지시).
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<BackgroundLayer, boolean> = {
white: true,
satellite: true,
hybrid: false,
};
const GIS_DEFAULT_ON: Record<GisLayer, boolean> = {
지적도: false,
행정구역_시군구: true,
행정구역_읍면동: true,
등고선: false,
도엽_등고선: false,
도엽_하천중심선: true,
도엽_표고점: false,
도엽_성절토: false,
도엽_옹벽석축: false,
};
/** 등고 라벨(계곡선 수치) 기본 표시 여부. */
const CONTOUR_LABEL_DEFAULT_ON = false;
/** 이만큼(px) 이하로 움직였다 뗐으면 클릭으로 본다 — 손떨림으로 선택이 안 되는 일을 막는다. */
const CLICK_SLOP_PX = 4;
/** 레이어별 색은 `ui_template_theme.css`의 `--map-*`가 정의처다. 여기는 이름만 잇는다.
* (fallback 값은 CSS가 아직 안 붙은 첫 프레임 대비용 안전값) */
const GIS_LAYER_COLOR_TOKENS: Record<GisLayer, [name: string, fallback: string]> = {
: ["--map-cadastral", "#f97316"],
_시군구: ["--map-sigungu", "#7c3aed"],
_읍면동: ["--map-eupmyeondong", "#22c55e"],
: ["--map-contour", "#fdba74"],
_등고선: ["--map-sheet-contour", "#a5b4fc"],
_하천중심선: ["--map-sheet-stream", "#2563eb"],
_표고점: ["--map-sheet-elev-point", "#f9a8d4"],
_성절토: ["--map-sheet-cutfill", "#f43f5e"],
_옹벽석축: ["--map-sheet-wall", "#0f766e"],
};
const gisLayerColor = (layer: GisLayer): string => themeColor(...GIS_LAYER_COLOR_TOKENS[layer]);
// 등고 라벨 표기 대상 레이어와 표고 속성 키 (gpkg=CTRLN_HG, 도엽=등고수치)
const CONTOUR_LABEL_KEYS: Partial<Record<GisLayer, string[]>> = {
: ["CTRLN_HG"],
_등고선: ["등고수치"],
};
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
@@ -341,12 +290,24 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
flowStatus.hidden = flowStatus.textContent === "";
scheduleDraw();
});
watershedButtons.append(watershed.button, ...watershed.partButtons, flowStrength.button);
// 상세 배수유역 — 관 매설 지점 편집과 세부유역 분할. 계산은 버튼을 눌렀을 때만 돈다.
const detailBasins = createDetailBasinOverlay(() => {
scheduleDraw();
});
watershedButtons.append(
watershed.button,
...watershed.partButtons,
flowStrength.button,
detailBasins.button,
...detailBasins.partButtons,
);
watershedGroup.append(watershedTitle, watershedButtons);
controls.insertBefore(watershedGroup, resetButton);
// 안내·결과 문구는 컨트롤 줄이 아니라 지도 위에 얹는다 — 컨트롤 영역 세로 공간을 먹지 않는다.
statusStack.append(watershed.statusElement, flowStatus);
statusStack.append(watershed.statusElement, flowStatus, detailBasins.statusElement);
statusTopRight.append(watershed.busyElement);
// 우클릭 메뉴는 뷰포트 기준 절대 위치라 뷰포트 안에 넣는다.
viewport.append(detailBasins.menuElement);
function updateImageTransform(): void {
backgroundImages.forEach((image) => {
@@ -455,6 +416,8 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
}
// 흐름 강도(도로 색·유입 집중점 마커)는 계획선 위에 얹는다.
flowStrength.draw(context, normalizer, view);
// 세부유역 채움과 관 마커는 맨 위 — 편집 대상이라 다른 레이어에 가려지면 집을 수 없다.
detailBasins.draw(context, normalizer, view);
updateImageTransform();
drawScaleBar(mapRect);
}
@@ -515,6 +478,8 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
planned.points.length > 1 ? prepareMetricPolyline(planned.points, nextMeta) : null;
// 흐름 강도는 계획선 위에 칠하므로 같은 점 목록·같은 메타를 쓴다(어긋나면 색이 밀린다).
flowStrength.setRoute(planned.points, nextMeta);
// 관 마커도 같은 계획선 위에 스냅한다 — 목록이 다르면 마커가 노선을 벗어난다.
detailBasins.setRoute(planned.points, nextMeta);
// 계획노선을 아직 올리지 않은 프로젝트에서는 켤 것이 없으니 버튼을 잠근다.
routeButton.disabled = routeLayer === null;
routeButton.title = routeLayer === null ? L("B04_Surface_Map_PlannedRouteEmpty") : "";
@@ -562,10 +527,43 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
},
{ passive: false },
);
/** 현재 화면 상태(팬·줌·지도 사각형). 마커 히트 판정과 그리기가 같은 값을 봐야 한다. */
function currentView(rect: DOMRect): ViewState {
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) };
}
viewport.addEventListener("contextmenu", (event) => {
const rect = viewport.getBoundingClientRect();
const opened = detailBasins.handleContextMenu(
currentView(rect),
event.clientX - rect.left,
event.clientY - rect.top,
);
// 계획선이나 관 마커 위에서만 전용 메뉴를 띄운다 — 그 밖은 브라우저 메뉴를 그대로 둔다.
if (opened) event.preventDefault();
});
viewport.addEventListener("pointerdown", (event) => {
// 중간 버튼은 브라우저 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹쳐 페이지 전체를
// 흔들므로 기본 동작을 차단하고 지도 팬으로만 사용한다.
if (event.button === 1) event.preventDefault();
// 관 마커를 잡았으면 끌기로 넘어간다 — 지도 팬도, 마커 선택도 하지 않는다.
if (event.button === 0) {
const rect = viewport.getBoundingClientRect();
if (
detailBasins.handlePointerDown(
currentView(rect),
event.clientX - rect.left,
event.clientY - rect.top,
)
) {
clickStart = null;
viewport.setPointerCapture(event.pointerId);
return;
}
}
// 팬은 가운데(휠) 버튼 전용이다. 좌버튼은 화면 위 객체를 고르는 데만 쓴다
// (좌버튼 드래그가 팬까지 겸하면 객체를 집으려다 지도가 딸려 움직인다 — 2026-08-01 사용자 지시).
// 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 할 수 있게 둔다.
@@ -582,24 +580,27 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
viewport.setPointerCapture(event.pointerId);
});
viewport.addEventListener("pointerup", (event) => {
// 관 마커를 끌던 중이었으면 그것으로 끝낸다 — 집중점 선택까지 겹쳐 일어나면 안 된다.
if (detailBasins.handlePointerUp()) return;
const start = clickStart;
clickStart = null;
if (!start || event.button !== 0) return;
if (Math.hypot(event.clientX - start.x, event.clientY - start.y) > CLICK_SLOP_PX) return;
const rect = viewport.getBoundingClientRect();
const width = Math.max(1, Math.floor(rect.width));
const height = Math.max(1, Math.floor(rect.height));
const view: ViewState = {
width,
height,
scale,
offsetX,
offsetY,
mapRect: computeMapRect(meta, width, height),
};
const view = currentView(rect);
flowStrength.handleClick(normalizer, view, event.clientX - rect.left, event.clientY - rect.top);
});
viewport.addEventListener("pointermove", (event) => {
const rect = viewport.getBoundingClientRect();
if (
detailBasins.handlePointerMove(
currentView(rect),
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;
@@ -625,8 +626,12 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
watershed.setProject(projectId);
flowStrength.clear();
flowStrength.setProject(projectId);
detailBasins.clear();
detailBasins.setProject(projectId);
void loadLayers();
},
/** 모델 확정과 함께 관 매설 지점·세부유역을 영구저장한다. */
commitDrainage: () => detailBasins.commit(),
dispose() {
loadSequence += 1;
if (frameHandle) {
@@ -635,6 +640,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
}
resizeObserver.disconnect();
flowStrength.dispose();
detailBasins.dispose();
},
};
}
@@ -402,6 +402,9 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
smooth: terrainViewer.isSmoothingEnabled(),
contour_interval_m: terrainViewer.getContourInterval(),
});
// 관 매설 지점·세부유역도 이때 함께 영구저장한다(2026-08-01 사용자 지시).
// 배수유역 분석 전이면 저장할 것이 없으므로 실패해도 모델 확정은 그대로 둔다.
await mapViewer.commitDrainage().catch(() => 0);
// 확정본이 바뀌었으므로 브라우저가 담아 둔 옛 자료를 더 이상 쓰지 않게 한다.
// 준비 표식을 지우면 아래 goToWorkflowStage가 준비 화면을 거쳐 새 자료를 담는다.
clearPreloadMark();
@@ -0,0 +1,69 @@
/* =============================================================================
* 계획선 1m 재표본 (B04 2D 지도 공용)
*
* 백엔드는 도로 위 값을 전부 **누가거리 1m 구간**으로 준다(흐름 강도 곡선, 관 매설 지점,
* 유입 집중점). 화면에서 그 값을 계획선 위에 얹으려면 같은 규칙으로 다시 찍은 점 목록이
* 있어야 한다 — 오버레이마다 따로 찍으면 한 칸씩 밀려 색과 마커가 어긋난다.
* ========================================================================== */
export type RoutePoint = { x: number; y: number };
/** 계획선을 1m 간격으로 다시 찍는다. 배열 인덱스 = 누가거리(m). */
export function resampleRoute(points: ReadonlyArray<RoutePoint>): RoutePoint[] {
const samples: RoutePoint[] = [];
if (points.length < 2) return samples;
let carried = 0;
samples.push({ x: points[0].x, y: points[0].y });
for (let i = 1; i < points.length; i += 1) {
const from = points[i - 1];
const to = points[i];
const dx = to.x - from.x;
const dy = to.y - from.y;
const length = Math.hypot(dx, dy);
if (length <= 0) continue;
let travelled = 1 - carried;
while (travelled <= length) {
samples.push({
x: from.x + (dx * travelled) / length,
y: from.y + (dy * travelled) / length,
});
travelled += 1;
}
carried = (carried + length) % 1;
}
return samples;
}
/** 누가거리(m) 위치의 계획선 좌표. 범위를 벗어나면 양 끝으로 자른다. */
export function pointAtChainage(
samples: ReadonlyArray<RoutePoint>,
chainage: number,
): RoutePoint | null {
if (samples.length === 0) return null;
const index = Math.min(samples.length - 1, Math.max(0, Math.round(chainage)));
return samples[index];
}
/** 화면 좌표에서 가장 가까운 계획선 위치를 찾는다. (누가거리 m, 화면 거리 px).
* 관을 우클릭으로 추가하거나 끌어 옮길 때 "계획선 위"로 스냅하는 근거다. 1m 표본을 전부
* 훑되 화면 변환은 넘겨받은 함수에 맡긴다 — 배경지도 메타를 여기서 알 필요가 없다. */
export function nearestChainage(
samples: ReadonlyArray<RoutePoint>,
toScreen: (point: RoutePoint) => [number, number],
screenX: number,
screenY: number,
): { chainage: number; distance: number } | null {
if (samples.length === 0) return null;
let bestIndex = -1;
let bestDistance = Number.POSITIVE_INFINITY;
for (let index = 0; index < samples.length; index += 1) {
const [x, y] = toScreen(samples[index]);
const distance = Math.hypot(x - screenX, y - screenY);
if (distance < bestDistance) {
bestDistance = distance;
bestIndex = index;
}
}
return bestIndex < 0 ? null : { chainage: bestIndex, distance: bestDistance };
}
@@ -623,6 +623,39 @@
box-shadow: inset 0 0 0 1px var(--b04-layer-color);
}
/* 관 매설 우클릭 메뉴 — 지도 뷰포트 기준 절대 위치. */
.b04-map__context-menu {
position: absolute;
z-index: 6;
display: flex;
flex-direction: column;
min-width: 140px;
padding: var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
box-shadow: 0 4px 16px rgb(0 0 0 / 25%);
}
.b04-map__context-menu[hidden] {
display: none;
}
.b04-map__context-menu-item {
padding: var(--spacing-8) var(--spacing-12);
border: 0;
border-radius: var(--radius-cards);
background: transparent;
color: var(--color-text-body);
font-size: 13px;
text-align: left;
cursor: pointer;
}
.b04-map__context-menu-item:hover {
background: var(--color-surface-sunken);
}
.b04-map__viewport {
position: relative;
width: 100%;
@@ -1,27 +0,0 @@
"""배수 관경 산정.
유역을 나누는 최종 목적은 지점의 파이프 관경 결정이다. 유역 경사면에 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
@@ -1,70 +1,42 @@
"""배수유역 세부 설계 (B05 — 일반 사용자용).
"""배수유역 세부 설계 B05 저장소 어댑터.
**분석 하지 않는다.** B04가 미리 돌려 저장한 결과를 읽어, 사용자가 실제로 손대는 가지만
처리한다(2026-07-31 사용자 지시).
계산 하지 않는다. 보충() 세부유역 분할() 알고리즘은 B04 관리자 화면과
공용이므로 `common_util_drainage_detail` 있고, 여기서는 **B05가 읽을 폴더만 정한다**
(2026-08-01 구조 개편).
간격이 최대치를 넘는 구간에 **최소 개수** 보충
측구 흐름으로 도로 담당 관을 정하고, 셀이 도달한 도로 셀의 담당 관을 그대로
셀의 유역 번호로 삼아 세부유역을 나눈다
사용자가 관을 옮기거나 추가하면 다시 돈다 격자 해석은 재사용한다
읽어 오는 (`B04_wf1_Surface/drainage/`):
· `03_road_routing.geojson` 계획도로선 · 기본 배관 · 2 전체 배수유역
· `03_road_routing.npz` 도로 귀속, 유하장, 강도, 도로 제원, 표고
화살표(방향 코드) 밴드 표고 같은 관리자 확인용 배열은 읽지 않는다 여기서는 필요 없고
파일만 무거워진다.
읽는 대상은 B04 원본이 아니라 **B05 사본**이다. B04가 다시 해석했으면 사본 먼저
갱신한다 편집분(`boundary_overrides.json`) 사본 갱신과 무관하게 남는다.
"""
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,
from common_util.common_util_drainage_detail import (
DrainageDetail,
RoadRouting,
WatershedBasin,
build_detail,
read_road_routing,
read_upstream_lines,
)
from common_util.common_util_route_geometry import RouteVertex
logger = logging.getLogger(__name__)
# 관 위치 선정 점수 배분 — 흐름 강도가 주, 종단 저점이 보조.
_SCORE_WEIGHT_STRENGTH = 0.7
_SCORE_WEIGHT_SAG = 0.3
# 성토부(내리막)는 물이 노선 밖으로 빠지므로 관 위치로 덜 선호한다.
_SCORE_FILL_PENALTY = 0.5
__all__ = [
"DrainageDetail",
"RoadRouting",
"WatershedBasin",
"build_drainage_detail",
"load_road_routing",
"load_upstream_lines",
]
@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 _synced_dir(stored_path: str) -> Path:
"""B04 원본을 B05 사본으로 맞춘 뒤 사본 폴더를 돌려준다."""
sync_from_b04(stored_path)
return b05_drainage_dir(stored_path)
def build_drainage_detail(
@@ -72,431 +44,15 @@ def build_drainage_detail(
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)
"""B04 분석 결과(B05 사본)를 읽어 관을 보충하고 세부유역을 나눈다."""
return build_detail(_synced_dir(stored_path), vertices, confirmed_chainages)
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"),
)
)
"""`03_road_routing` 산출물을 B05 사본에서 읽는다."""
return read_road_routing(_synced_dir(stored_path))
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
"""상류 세류망을 B05 사본에서 읽는다(화면 강조용)."""
return read_upstream_lines(b05_drainage_dir(stored_path))
@@ -17,8 +17,8 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import (
SectionGenerationOptions,
generate_sections,
)
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Sampler import build_surface_sampler
from common_util.common_util_json import atomic_write_json
from common_util.common_util_surface_sampler import build_surface_sampler
from config.config_system import FOREST_ROAD_PROFILE_CRITERIA
logger = logging.getLogger(__name__)
@@ -11,7 +11,7 @@ from typing import Any
import numpy as np
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Sampler import SurfaceElevationSampler
from common_util.common_util_surface_sampler import SurfaceElevationSampler
from config.config_system import (
SECTION_CROSS_HALF_WIDTH_M,
SECTION_CROSS_SAMPLE_INTERVAL_M,
+21 -39
View File
@@ -14,7 +14,6 @@ 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
@@ -25,12 +24,8 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Drainage_Store import (
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 common_util.common_util_drainage_context import DrainageContext, load_drainage_context
from common_util.common_util_route_geometry import StructureCandidate
from config.config_db import get_db_pool
from config.config_system import DRAINAGE_BOUNDARY_HANDLE_SPACING_M
@@ -51,36 +46,21 @@ def _candidate_payload(candidate: StructureCandidate, to_lonlat: Any) -> dict[st
}
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
)
async def _prepare(project_id: UUID) -> DrainageContext | JSONResponse:
"""노선·종단 Z·좌표 변환기를 준비한다. 도엽 피처는 읽지 않는다(분석을 안 하므로).
vertices = build_route_vertices(points)
if len(vertices) < 2:
노선 기준선은 원청 계획노선 CSV다 B04 격자가 노선으로 도로 셀을 구웠기 때문이다.
종단 Z만 B05 계획고/경로 정점 우선으로 갈아 끼운다(공용 준비기가 판단).
"""
context, reason = await load_drainage_context(project_id)
if context is None:
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
if context.route_id is None:
return JSONResponse(
status_code=400,
content={"status": "error", "message": "노선 좌표가 부족합니다."},
status_code=404,
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),
}
return context
@router.post("/{project_id}/drainage/basins", response_model=None)
@@ -100,7 +80,7 @@ async def post_drainage_basins(
confirmed = _parse_chainages(raw) if isinstance(raw, list) else []
detail = await asyncio.to_thread(
build_drainage_detail, prepared["stored_path"], prepared["vertices"], confirmed
build_drainage_detail, prepared.stored_path, prepared.vertices, confirmed
)
if detail is None:
return JSONResponse(
@@ -111,21 +91,23 @@ async def post_drainage_basins(
},
)
to_lonlat = prepared["to_lonlat"]
to_lonlat = prepared.to_lonlat
# 2차 전체 유역 외곽선 — 편집 핸들 간격으로 다시 찍고 저장된 편집분을 얹는다.
# 격자 경계라 원래 정점이 1m 간격이라 그대로는 손으로 잡을 수 없다.
boundary = resample_boundary(detail.basin_lonlat)
stored_overrides = load_boundary_overrides(prepared["stored_path"])
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)
save_boundary_overrides(prepared.stored_path, kept)
return {
"status": "success",
"project_id": str(project_id),
"route_id": prepared["route_id"],
"route_id": prepared.route_id,
# 종단 Z를 어디서 가져왔는지 — 관 담당 구간이 갈리는 근거라 화면에서 확인 가능해야 한다.
"z_source": prepared.z_source,
# B04가 남긴 그대로 — 계획도로선. 외곽선만 편집분을 반영해 내보낸다.
"route_lonlat": detail.route_lonlat,
"main_polygon_lonlat": boundary,
@@ -208,11 +208,13 @@ class _SectionGeometry:
# 절/성토 역할은 손대지 않는다. ground_at이 없으면 section_mode 기반 역할을 쓴다.
if ground_at is not None:
self.left_role = (
"cut" if ground_at(self.left_extent) > self.road_z(self.left_extent) + 1e-3
"cut"
if ground_at(self.left_extent) > self.road_z(self.left_extent) + 1e-3
else "fill"
)
self.right_role = (
"cut" if ground_at(-self.right_extent) > self.road_z(-self.right_extent) + 1e-3
"cut"
if ground_at(-self.right_extent) > self.road_z(-self.right_extent) + 1e-3
else "fill"
)
@@ -583,42 +585,3 @@ def compute_cross_design(
if preset_key == "rock" and rock_boundary_offset_m is not None:
result["rock_boundary_offset_m"] = round(float(rock_boundary_offset_m), 4)
return result
def design_elevation_from_longitudinal(
longitudinal: dict[str, Any], chainage_m: float
) -> float | None:
"""종단 계획선(design_profiles) 샘플을 chainage 기준 선형보간해 계획고를 구한다.
프론트 designElevationAt과 동일 규칙(범위 끝값 클램프). 계획선이 없으면
None을 반환해 지반고 폴백/오류 처리를 호출부에 맡긴다.
"""
profiles = longitudinal.get("design_profiles") if isinstance(longitudinal, dict) else None
if not isinstance(profiles, list) or not profiles:
return None
samples = [
s
for s in profiles[0].get("samples", [])
if isinstance(s.get("elevation_m"), (int, float))
and isinstance(s.get("chainage_m"), (int, float))
]
if not samples:
return None
if chainage_m <= samples[0]["chainage_m"]:
return float(samples[0]["elevation_m"])
last = samples[-1]
if chainage_m >= last["chainage_m"]:
return float(last["elevation_m"])
for index in range(1, len(samples)):
previous = samples[index - 1]
current = samples[index]
if chainage_m > current["chainage_m"]:
continue
span = current["chainage_m"] - previous["chainage_m"]
if span <= 0:
return float(current["elevation_m"])
ratio = (chainage_m - previous["chainage_m"]) / span
return float(
previous["elevation_m"] + (current["elevation_m"] - previous["elevation_m"]) * ratio
)
return float(last["elevation_m"])
@@ -19,10 +19,7 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Sections import (
)
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import SectionGenerationOptions
from B05_wf2_Route.B05_wf2_Route_Router_Confirm import _merge_uphill_overrides_into_longitudinal
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Design import (
compute_cross_design,
design_elevation_from_longitudinal,
)
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Design import compute_cross_design
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import (
confirm_sections_for_route,
count_cross_sections,
@@ -56,6 +53,7 @@ from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import (
SectionSummaryResponse,
)
from common_util.common_util_auth import verify_session
from common_util.common_util_route_profile import design_elevation_from_longitudinal
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from common_util.common_util_workflow_state import complete_stage, get_workflow_state
@@ -115,9 +113,7 @@ async def get_forest_road_min_widths(project_id: UUID) -> dict[str, dict[str, fl
# 아래 두 엔드포인트는 `/sections/{route_id}`(int)보다 먼저 선언해 라우팅 충돌을 막는다.
@router.get(
"/{project_id}/sections/company-standards", response_model=CompanyStandardListResponse
)
@router.get("/{project_id}/sections/company-standards", response_model=CompanyStandardListResponse)
async def list_company_standards(
project_id: UUID, session: dict[str, Any] = Depends(verify_session)
) -> CompanyStandardListResponse:
@@ -15,9 +15,6 @@ import math
from typing import Any
from uuid import uuid5
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Design import (
design_elevation_from_longitudinal,
)
from B07_wf4_DesignDetail.B07_wf4_DesignDetail_Engine_Cad import (
_ENTITY_NS,
DESIGN_COLOR,
@@ -48,6 +45,7 @@ from B07_wf4_DesignDetail.B07_wf4_DesignDetail_Engine_Template import (
entities_bbox,
frame_entities,
)
from common_util.common_util_route_profile import design_elevation_from_longitudinal
# 종단 전용 레이어: 그래프 축·격자(잠금 — 참조용, 편집 제외).
LONG_GRID_LAYER_ID = "b07-long-grid"
@@ -13,10 +13,7 @@ from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_wf2_Route.B05_wf2_Route_Engine_Sections import prune_stale_cross_files
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Design import (
compute_cross_design,
design_elevation_from_longitudinal,
)
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Design import compute_cross_design
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import (
get_confirmed_route_context,
get_cross_section_design,
@@ -43,6 +40,7 @@ from B07_wf4_DesignDetail.B07_wf4_DesignDetail_Schema import (
DesignDrawingListResponse,
DesignDrawingResponse,
)
from common_util.common_util_route_profile import design_elevation_from_longitudinal
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_workflow_state import complete_stage, start_stage
from config.config_db import get_db_pool
+131
View File
@@ -0,0 +1,131 @@
"""배수유역 세부 설계 입력 준비 (B04 관리자 화면 · B05 사용자 화면 공용).
화면이 같은 목록과 같은 세부유역을 보여 주려면 **입력이 글자도 달라선 된다**
(2026-08-01 사용자 지시). 그래서 노선·종단 Z·좌표계를 여기 곳에서 만들어 양쪽에 넘긴다.
노선 기준선은 B05가 최적 경로가 아니라 **B03이 업로드한 원청 계획노선 CSV**. B04 격자
해석이 노선으로 도로 셀을 구웠으므로, 다른 노선의 누가거리를 쓰면 도로 셀과 위치가
어긋난다. 종단 Z만 상황에 따라 갈아 끼운다 [[common_util_route_profile]].
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from uuid import UUID
from pyproj import Transformer
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_wf2_Route.B05_wf2_Route_Repository import (
get_latest_route,
get_route_points,
get_surface_crs_epsg,
)
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import get_longitudinal_section
from common_util.common_util_route_geometry import (
RouteVertex,
build_route_vertices,
find_planned_route_file,
read_planned_route_csv,
)
from common_util.common_util_route_profile import Z_SOURCE_CSV, resolve_route_profile
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from common_util.common_util_surface_sampler import build_surface_sampler
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
_INPUT_SUBDIR = Path("B03_FileInput") / "input"
_MODELS_SUBDIR = Path("B04_wf1_Surface") / "models"
@dataclass
class DrainageContext:
"""세부 설계 한 번에 필요한 입력 묶음."""
stored_path: str
project_root: Path
vertices: list[RouteVertex] = field(default_factory=list)
z_source: str = Z_SOURCE_CSV
epsg: int = 5186
route_id: int | None = None
to_lonlat: Callable[[float, float], tuple[float, float]] = lambda x, y: (x, y)
async def load_drainage_context(project_id: UUID) -> tuple[DrainageContext | None, str]:
"""노선·종단 Z·좌표계를 준비한다. 실패하면 (None, 사용자에게 보일 사유).
B05 확정 경로는 **있으면 쓰고 없으면 넘어간다** B04는 WF1 화면이라 아직 경로가 없다.
"""
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 None, "프로젝트 저장 경로가 없습니다."
route = await get_latest_route(connection, project_id)
route_points: list[dict[str, Any]] = []
longitudinal: dict[str, Any] | None = None
if route:
route_points = await get_route_points(connection, int(route["id"]))
section = await get_longitudinal_section(connection, project_id, int(route["id"]))
longitudinal = (section or {}).get("data")
surface_model_id = (route or {}).get("surface_model_id")
db_epsg = await get_surface_crs_epsg(
connection, project_id, int(surface_model_id) if surface_model_id else 0
)
surface_params = await get_surface_confirmation_params(connection, str(project_id))
project_root = Path(resolve_stored_project_path(stored_path))
planned = await asyncio.to_thread(_read_planned_route, project_root)
if planned is None or len(planned.vertices) < 2:
return None, "원청 계획노선 CSV를 읽지 못했습니다. B03에서 노선 파일을 확인하세요."
sampler = await asyncio.to_thread(_open_sampler, project_root, surface_params)
vertices, z_source = await asyncio.to_thread(
resolve_route_profile,
planned.vertices,
route_vertices=build_route_vertices(route_points) if route_points else None,
longitudinal=longitudinal,
sampler=sampler,
)
epsg = int(planned.epsg or db_epsg or 5186)
transformer = Transformer.from_crs(f"EPSG:{epsg}", "EPSG:4326", always_xy=True)
return (
DrainageContext(
stored_path=stored_path,
project_root=project_root,
vertices=vertices,
z_source=z_source,
epsg=epsg,
route_id=int(route["id"]) if route else None,
to_lonlat=lambda x, y: transformer.transform(x, y),
),
"",
)
def _read_planned_route(project_root: Path):
"""원청 계획노선 CSV를 찾아 읽는다(파일 접근이라 스레드에서 돈다)."""
path = find_planned_route_file(project_root / _INPUT_SUBDIR)
return read_planned_route_csv(path) if path else None
def _open_sampler(project_root: Path, surface_params: dict[str, Any]):
"""확정 지표면 표고 sampler를 연다. 모델이 없으면 None(종단 Z가 다른 경로로 폴백)."""
try:
return build_surface_sampler(
project_root / _MODELS_SUBDIR,
str(surface_params["source_filter"]),
str(surface_params["method"]),
bool(surface_params["smooth"]),
)
except (FileNotFoundError, ValueError, OSError) as exc:
logger.warning("배수유역: 확정 지표면 sampler를 열지 못했습니다 — %s", exc)
return None
+517
View File
@@ -0,0 +1,517 @@
"""배수유역 세부 설계 공용 엔진 (B04 관리자 화면 · B05 사용자 화면 공용).
**격자 해석은 하지 않는다.** B04 배수유역 분석이 미리 돌려 저장한 결과를 읽어, 사용자가
실제로 손대는 가지만 처리한다(2026-07-31 사용자 지시).
간격이 최대치를 넘는 구간에 **최소 개수** 관을 보충
측구 흐름으로 도로 담당 관을 정하고, 셀이 도달한 도로 셀의 담당 관을 그대로
셀의 유역 번호로 삼아 세부유역을 나눈다
사용자가 관을 옮기거나 추가하면 다시 돈다 격자 해석은 재사용한다
읽어 오는 (`{배수유역 폴더}/`):
· `03_road_routing.geojson` 계획도로선 · 기본 배관 · 2 전체 배수유역
· `03_road_routing.npz` 도로 귀속, 유하장, 강도, 도로 제원, 표고
화살표(방향 코드) 밴드 표고 같은 관리자 확인용 배열은 읽지 않는다 여기서는 필요 없고
파일만 무거워진다.
** common_util인가**: B04(관리자 트러블슈팅) B05(일반 사용자) 같은 이름의 버튼을
누르면 같은 결과가 나와야 한다(2026-08-01 사용자 지시). 벌로 두면 언젠가 갈라진다.
읽는 폴더만 다르므로 폴더를 인자로 받고, B04/B05 각자의 어댑터가 경로를 정한다.
격자 산출물의 규격(`GridSpec`·`STAGES`·`polygonize_labels`) B04가 만든 것이므로
정의처인 B04 엔진을 그대로 참조한다(역방향 참조 없음).
"""
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 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:
"""세부 설계 산출물 — 화면에 그릴 기하와 세부유역."""
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가 계산해 둔 평균 흐름 화살표를 그대로 넘긴다 — 여기서 다시 계산하지 않는다.
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)
@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가 남긴 배수유역 분석 결과 — 세부유역을 나누는 데 필요한 최소 묶음."""
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 build_detail(
directory: Path,
vertices: list[RouteVertex],
confirmed_chainages: list[float] | None = None,
) -> DrainageDetail | None:
"""B04 분석 결과를 읽어 관을 보충하고 세부유역을 나눈다.
`confirmed_chainages` 주면 위치를 관으로 확정하고(사용자 편집), 비우면 B04의
기본 관에 최대 간격 규칙으로 최소 개수만 보충한다. 어느 쪽이든 격자 해석은 하지 않는다.
"""
routing = read_road_routing(directory)
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=read_upstream_lines(directory),
)
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
def read_road_routing(directory: Path) -> RoadRouting | None:
"""`03_road_routing` 산출물을 읽는다. 없으면 None."""
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 read_upstream_lines(directory: Path) -> list[list[list[float]]]:
"""`01_primary_region`에서 상류 세류망만 읽는다(화면 강조용).
유역 판정의 기준선이라 B04 오버레이에서도 같은 선을 굵게 그린다 B05는 선을
그대로 받아 표시만 한다.
"""
path = directory / 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(
routing: RoadRouting,
pipes: list[StructureCandidate],
pipe_of_slot: np.ndarray,
) -> list[WatershedBasin]:
"""셀이 도달한 도로 셀의 담당 관을 그대로 유역 번호로 삼아 세부유역을 만든다."""
spec = routing.spec
labels = np.full(spec.size, -1, dtype=np.int32)
reached = routing.road_slot >= 0
labels[reached] = pipe_of_slot[routing.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 = routing.elevation[member]
highest = float(np.nanmax(elevations)) if np.isfinite(elevations).any() else 0.0
outlet_z = _outlet_elevation(routing, order, pipe_of_slot)
area = count * cell_area
relief = max(0.0, highest - outlet_z)
flow_length = float(routing.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(routing: 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 = routing.elevation[routing.road_cell_index[slots]]
finite = elevations[np.isfinite(elevations)]
return float(finite.min()) if finite.size else 0.0
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
+139
View File
@@ -0,0 +1,139 @@
"""관 매설 지점 정본 저장소 (B04 관리자 화면 · B05 사용자 화면 공용).
해석 산출물(`01~03`) 다시 돌리면 덮어써도 되지만, 사용자가 찍고 옮긴 관은 그러면 된다.
그래서 편집분만 `{배수유역 폴더}/edits/pipe_points.json` 따로 남기고 화면이 같은 파일을
읽고 쓴다 관리자 화면에서 옮긴 관이 사용자 화면에서 다르게 보이면 되기 때문이다
(2026-08-01 사용자 지시).
위치는 좌표가 아니라 **누가거리(chainage_m)** 저장한다. 지면 필터나 지표면 모델을 바꾸면
종단 Z가 달라지지만 관이 놓인 자리는 그대로여야 하고, 그때는 세부유역만 다시 나누면 된다.
노선 자체가 바뀌면(`route_signature` 불일치) 기준이 사라지므로 전량 버리고 다시 만든다.
"""
from __future__ import annotations
import hashlib
import json
import logging
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_json import atomic_write_json
from common_util.common_util_route_geometry import RouteVertex
from config.config_system import (
DRAINAGE_DETAIL_FILENAME,
DRAINAGE_EDITS_DIRNAME,
DRAINAGE_PIPE_POINTS_FILENAME,
)
logger = logging.getLogger(__name__)
# 관이 그 자리에 있는 이유. 화면 마커 모양과 "자동/수동" 구분이 여기에 달려 있다.
PIPE_SOURCE_STREAM = "stream" # 기본 관 — 도로 × 상류 세류선 교차점
PIPE_SOURCE_SPACING = "spacing" # 자동 보충 — 관 최대 간격 규칙
PIPE_SOURCE_USER = "user" # 수동 — 사용자가 우클릭으로 추가하거나 옮긴 관
_KNOWN_SOURCES = (PIPE_SOURCE_STREAM, PIPE_SOURCE_SPACING, PIPE_SOURCE_USER)
@dataclass
class PipePoint:
"""계획선 위 관 매설 지점 한 개."""
chainage_m: float
source: str = PIPE_SOURCE_USER
def as_dict(self) -> dict[str, Any]:
return {"chainage_m": round(float(self.chainage_m), 2), "source": self.source}
def edits_dir(stored_path: str) -> Path:
return drainage_dir(stored_path) / DRAINAGE_EDITS_DIRNAME
def pipe_points_path(stored_path: str) -> Path:
return edits_dir(stored_path) / DRAINAGE_PIPE_POINTS_FILENAME
def detail_basins_path(stored_path: str) -> Path:
return drainage_dir(stored_path) / DRAINAGE_DETAIL_FILENAME
def route_signature(vertices: list[RouteVertex]) -> str:
"""노선이 바뀌었는지 판별할 지문. 정점 좌표를 0.01m로 끊어 해시한다.
연장만 보면 노선이 통째로 옮겨져도 같은 값이 나온다. 좌표를 넣되 소수점을 끊어
부동소수 잡음으로 지문이 흔들리지 않게 한다.
"""
digest = hashlib.sha1(usedforsecurity=False)
for vertex in vertices:
digest.update(f"{vertex.x:.2f},{vertex.y:.2f};".encode())
return f"{len(vertices)}-{digest.hexdigest()[:16]}"
def load_pipe_points(stored_path: str, signature: str) -> list[PipePoint] | None:
"""저장된 관 지점을 읽는다. 파일이 없거나 노선이 바뀌었으면 None(= 다시 만들어야 함)."""
path = pipe_points_path(stored_path)
if not path.exists():
return None
try:
with path.open("r", encoding="utf-8") as file:
document = json.load(file)
except (OSError, json.JSONDecodeError):
logger.warning("배수유역: 관 지점 파일을 읽지 못했습니다 (%s).", path)
return None
stored_signature = str(document.get("route_signature") or "")
if stored_signature != signature:
logger.info("배수유역: 노선이 바뀌어 저장된 관 지점을 버립니다 (%s).", path.name)
return None
return parse_pipe_points(document.get("points"))
def parse_pipe_points(values: Any) -> list[PipePoint]:
"""외부에서 들어온 관 목록(파일·요청 본문)을 정리한다. 누가거리 오름차순."""
if not isinstance(values, list):
return []
points: list[PipePoint] = []
for item in values:
if isinstance(item, (int, float)):
points.append(PipePoint(chainage_m=float(item)))
continue
if not isinstance(item, dict):
continue
chainage = item.get("chainage_m")
if not isinstance(chainage, (int, float)):
continue
source = str(item.get("source") or PIPE_SOURCE_USER)
points.append(
PipePoint(
chainage_m=float(chainage),
source=source if source in _KNOWN_SOURCES else PIPE_SOURCE_USER,
)
)
points.sort(key=lambda point: point.chainage_m)
return points
def save_pipe_points(stored_path: str, signature: str, points: list[PipePoint]) -> int:
"""관 지점을 정본 파일에 쓴다. 저장된 개수를 돌려준다."""
path = pipe_points_path(stored_path)
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(
path,
{
"route_signature": signature,
"points": [point.as_dict() for point in points],
},
)
logger.info("배수유역: 관 지점 %d개를 저장했습니다 (%s).", len(points), path.name)
return len(points)
def save_detail_basins(stored_path: str, features: list[dict[str, Any]]) -> Path:
"""세부유역을 GeoJSON으로 남긴다(파생물 — 관 지점만 있으면 언제든 다시 만든다)."""
path = detail_basins_path(stored_path)
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(path, {"type": "FeatureCollection", "features": features})
logger.info("배수유역: 세부유역 %d개를 저장했습니다 (%s).", len(features), path.name)
return path
+173
View File
@@ -0,0 +1,173 @@
"""계획노선 종단 Z 해석 (공용).
배수유역 세부 설계는 **노면 물이 어느 쪽으로 흐르는가** 담당 구간을 나눈다. 그래서
노선 정점의 Z가 무엇이냐에 따라 결과가 통째로 달라진다. B04(관리자) B05(사용자) 서로
다른 Z를 쓰면 같은 프로젝트에서 세부유역이 갈라지므로, 어느 Z를 쓸지는 여기 곳에서만
정한다(2026-08-01 사용자 지시).
우선순위
B05 종단 계획고(`longitudinal_sections.data.design_profiles`) 사용자가 편집을 마친 정본
B05 경로 정점 Z(`route_points.z`) 계획고 편집 단계
확정 지표면 모델 샘플링 B05를 아직 지나지 않은 B04 시점의 기본값
원청 계획노선 CSV의 z 셋이 모두 없을 때의 최후 폴백
B05가 **최적 경로** 기하 위의 값이다. 원청 계획노선과 노선 자체가 다르면 같은
누가거리라도 다른 지점이므로, 노선이 실질적으로 같을 때만 채택하고 아니면 으로 내려간다.
DB나 페이지 모듈은 여기서 건드리지 않는다 호출부(B04/B05 라우터) 읽어서 넘긴다.
"""
from __future__ import annotations
import logging
import math
from dataclasses import replace
from typing import Any
import numpy as np
from common_util.common_util_route_geometry import RouteVertex, interpolate_vertex
from common_util.common_util_surface_sampler import SurfaceElevationSampler
logger = logging.getLogger(__name__)
# 노선 동일성 판정 — 시·종점이 이 거리 안이고 연장 차이가 아래 비율 안이면 같은 노선으로 본다.
# 최적 경로는 원청 노선을 따라가되 격자 해상도만큼 흔들리므로 여유를 둔다.
ROUTE_MATCH_ENDPOINT_TOLERANCE_M = 20.0
ROUTE_MATCH_LENGTH_TOLERANCE = 0.05
# Z 출처 표시 — 화면과 로그가 어느 종단을 쓴 결과인지 알 수 있어야 한다.
Z_SOURCE_DESIGN = "design_profile"
Z_SOURCE_ROUTE_POINTS = "route_points"
Z_SOURCE_SURFACE = "surface"
Z_SOURCE_CSV = "csv"
def design_elevation_from_longitudinal(
longitudinal: dict[str, Any], chainage_m: float
) -> float | None:
"""종단 계획선(design_profiles) 샘플을 chainage 기준 선형보간해 계획고를 구한다.
프론트 designElevationAt과 동일 규칙(범위 끝값 클램프). 계획선이 없으면
None을 반환해 지반고 폴백/오류 처리를 호출부에 맡긴다.
"""
profiles = longitudinal.get("design_profiles") if isinstance(longitudinal, dict) else None
if not isinstance(profiles, list) or not profiles:
return None
samples = [
s
for s in profiles[0].get("samples", [])
if isinstance(s.get("elevation_m"), (int, float))
and isinstance(s.get("chainage_m"), (int, float))
]
if not samples:
return None
if chainage_m <= samples[0]["chainage_m"]:
return float(samples[0]["elevation_m"])
last = samples[-1]
if chainage_m >= last["chainage_m"]:
return float(last["elevation_m"])
for index in range(1, len(samples)):
previous = samples[index - 1]
current = samples[index]
if chainage_m > current["chainage_m"]:
continue
span = current["chainage_m"] - previous["chainage_m"]
if span <= 0:
return float(current["elevation_m"])
ratio = (chainage_m - previous["chainage_m"]) / span
return float(
previous["elevation_m"] + (current["elevation_m"] - previous["elevation_m"]) * ratio
)
return float(last["elevation_m"])
def routes_match(left: list[RouteVertex], right: list[RouteVertex]) -> bool:
"""두 노선이 실질적으로 같은 노선인지 본다(시·종점 근접 + 연장 유사).
최적 경로는 원청 노선을 격자 위에서 다시 그은 것이라 정점이 하나도 겹치지 않을
있다. 그래서 정점 대조가 아니라 끝점과 연장으로만 판단한다.
"""
if len(left) < 2 or len(right) < 2:
return False
start_gap = math.dist((left[0].x, left[0].y), (right[0].x, right[0].y))
end_gap = math.dist((left[-1].x, left[-1].y), (right[-1].x, right[-1].y))
if max(start_gap, end_gap) > ROUTE_MATCH_ENDPOINT_TOLERANCE_M:
return False
left_length = left[-1].chainage_m
right_length = right[-1].chainage_m
if left_length <= 0 or right_length <= 0:
return False
return abs(left_length - right_length) / left_length <= ROUTE_MATCH_LENGTH_TOLERANCE
def resolve_route_profile(
vertices: list[RouteVertex],
*,
route_vertices: list[RouteVertex] | None = None,
longitudinal: dict[str, Any] | None = None,
sampler: SurfaceElevationSampler | None = None,
) -> tuple[list[RouteVertex], str]:
"""계획노선 정점에 종단 Z를 채워 돌려준다. (정점 목록, Z 출처) 형태.
`vertices` 원청 계획노선(B04·B05 공용 기준선)이고, `route_vertices` B05가 최적
경로다. 노선이 같을 때만 쓰고, 아니면 (지표면 샘플링)으로 내려간다.
"""
if len(vertices) < 2:
return vertices, Z_SOURCE_CSV
matched = bool(route_vertices) and routes_match(vertices, route_vertices or [])
if matched and longitudinal:
elevations = [
design_elevation_from_longitudinal(longitudinal, vertex.chainage_m)
for vertex in vertices
]
if all(value is not None for value in elevations):
logger.info("노선 종단 Z: B05 계획고(design_profiles) 채택 — 정점 %d", len(vertices))
return (
[replace(v, z=float(z)) for v, z in zip(vertices, elevations)],
Z_SOURCE_DESIGN,
)
if matched and route_vertices:
logger.info("노선 종단 Z: B05 경로 정점(route_points) 채택 — 정점 %d", len(vertices))
return (
[replace(v, z=interpolate_vertex(route_vertices, v.chainage_m)[2]) for v in vertices],
Z_SOURCE_ROUTE_POINTS,
)
if sampler is not None:
sampled = _sample_z(vertices, sampler)
if sampled is not None:
logger.info("노선 종단 Z: 확정 지표면 샘플링 채택 — 정점 %d", len(vertices))
return sampled, Z_SOURCE_SURFACE
logger.info("노선 종단 Z: 원청 CSV z 유지 — 정점 %d", len(vertices))
return vertices, Z_SOURCE_CSV
def _sample_z(
vertices: list[RouteVertex], sampler: SurfaceElevationSampler
) -> list[RouteVertex] | None:
"""확정 지표면에서 노선 정점의 지반고를 뽑는다.
모델 밖으로 나간 정점은 유효한 이웃 정점 값으로 메운다 노선 한두 점이 DTM 가장자리를
벗어났다고 종단 전체를 버리면 세부유역을 나눈다. 유효한 값이 하나도 없으면 None.
"""
xy = np.array([[vertex.x, vertex.y] for vertex in vertices], dtype=np.float64)
try:
z, valid = sampler.sample_xy(xy)
except (ValueError, OSError) as exc:
logger.warning("노선 종단 Z: 지표면 샘플링 실패 — %s", exc)
return None
if not bool(valid.any()):
logger.warning("노선 종단 Z: 노선이 확정 지표면 범위 밖입니다.")
return None
if not bool(valid.all()):
index = np.arange(z.size)
known = index[valid]
z = np.interp(index, known, z[valid])
logger.info(
"노선 종단 Z: 지표면 밖 정점 %d개를 이웃 값으로 메웠습니다.", int((~valid).sum())
)
return [replace(vertex, z=float(value)) for vertex, value in zip(vertices, z)]
@@ -1,9 +1,13 @@
"""B05 종횡단 계산용 지표면 표고 sampler.
"""확정 지표면 모델 표고 sampler (공용).
·횡단 생성기가 의존하는 최소 표고 조회 인터페이스와, 확정된 지표면 모델
(B04_wf1_Surface/models) 일괄 XY 표고 sampler로 여는 팩토리를 제공한다.
DTM valid_mask를 footprint로 결합해 데이터가 없는 영역을 임의 표고로 메우지
않는다.
B05 종횡단 전용이었으나 B04 배수유역 세부 설계도 같은 표고면을 써야 해서
common_util로 옮겼다 화면의 종단 Z가 갈라지면 세부유역 경계가 달라진다
(2026-08-01 구조 개편).
"""
from collections.abc import Callable
+10 -2
View File
@@ -277,9 +277,9 @@ DRAINAGE_CONTOUR_CLIP_MARGIN_M = float(os.getenv("DRAINAGE_CONTOUR_CLIP_MARGIN_M
# 평탄면 해소용 미세 경사(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_MAX_SPACING_M = float(os.getenv("DRAINAGE_PIPE_MAX_SPACING_M", "100.0"))
# 관끼리 이보다 가까우면 같은 계곡으로 보고 하나로 합친다.
DRAINAGE_PIPE_MIN_SPACING_M = float(os.getenv("DRAINAGE_PIPE_MIN_SPACING_M", "20.0"))
DRAINAGE_PIPE_MIN_SPACING_M = float(os.getenv("DRAINAGE_PIPE_MIN_SPACING_M", "5.0"))
# 측구 흐름(도로 셀 → 담당 관) 판정용 종단 계획선 샘플 간격(m).
DRAINAGE_DITCH_SAMPLE_M = float(os.getenv("DRAINAGE_DITCH_SAMPLE_M", "1.0"))
# 유역 폴리곤 단순화 허용오차(m). 격자 계단 경계를 매끄럽게 줄여 응답 크기를 낮춘다.
@@ -292,6 +292,14 @@ DRAINAGE_CACHE_FILENAME = "watershed_grid.npz"
# 분석 응답 자체를 그대로 담아 두는 파일. 재산정하지 않는 한 이걸 그대로 돌려준다 —
# 저장 배열에서 응답을 다시 조립하면 원본과 어긋날 여지가 생긴다(2026-07-31 사용자 지시).
DRAINAGE_RESPONSE_FILENAME = "00_watershed_response.json"
# 세부유역 산출물. 관 목록이 정해져야 나오므로 해석 산출물(01~03)과 번호를 이어 붙인다.
DRAINAGE_DETAIL_FILENAME = "04_detailed_basins.geojson"
# ── 관 매설 지점 편집분 ──
# 해석 산출물(01~03)은 다시 돌리면 덮어써도 되지만 사용자가 찍은 관은 그러면 안 된다.
# 같은 폴더 아래 편집분 전용 칸을 따로 두고 B04(관리자)·B05(사용자)가 같은 파일을 본다.
DRAINAGE_EDITS_DIRNAME = "edits"
DRAINAGE_PIPE_POINTS_FILENAME = "pipe_points.json"
# ── B05 전용 배수유역 사본 ──
# B04 산출물을 그대로 쓰면 B05 편집이 원본을 덮어쓴다. 프로젝트 저장소의
+2
View File
@@ -29,6 +29,7 @@ from B01_Dashboard.B01_Dashboard_Router import router as b01_dashboard_router
from B02_ProjRegister.B02_ProjRegister_Router import router as b02_proj_register_router
from B03_FileInput.B03_FileInput_Router import router as b03_file_input_router
from B04_wf1_Surface.B04_wf1_Surface_Router import router as b04_surface_router
from B04_wf1_Surface.B04_wf1_Surface_Router_Basins import router as b04_basins_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
@@ -350,6 +351,7 @@ app.include_router(b04_surface_contour_router, dependencies=protected_with_compa
app.include_router(b04_surface_gis_router, dependencies=protected_with_company)
app.include_router(b04_watershed_router, dependencies=protected_with_company)
app.include_router(b04_inflow_router, dependencies=protected_with_company)
app.include_router(b04_basins_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)
+32
View File
@@ -719,6 +719,38 @@ export const ui_locales = {
"유입 셀을 불러오지 못했습니다.",
"Failed to load the contributing cells.",
],
/* 상세 배수유역 (관 매설 지점 편집 + 세부유역 분할) */
B04_Surface_Basin_Btn: ["상세유역 분석", "Detail basins"],
B04_Surface_Basin_Btn_Busy: ["분석 중…", "Analyzing…"],
B04_Surface_Basin_Btn_Tip: [
"관 매설 지점을 기준으로 세부 배수유역을 나눕니다. 계획선 위에서 우클릭하면 관을 추가하고, 마커 위에서 우클릭하면 삭제합니다. 마커를 끌면 계획선을 따라 옮겨집니다.",
"Splits the basin per culvert. Right-click the route to add a culvert, right-click a marker to remove it, and drag a marker to slide it along the route.",
],
B04_Surface_Basin_Part_Pipes: ["관 매설", "Culverts"],
B04_Surface_Basin_Part_Basins: ["세부 유역", "Sub-basins"],
B04_Surface_Basin_Menu_Add: ["관 매설 추가", "Add culvert"],
B04_Surface_Basin_Menu_Delete: ["관 매설 삭제", "Remove culvert"],
/* {pipes}=관 개수, {stream}=기본, {spacing}=자동 보충, {user}=수동, {basins}=세부유역 수, {source}=종단 Z 출처 */
B04_Surface_Basin_Summary: [
"관 {pipes}개(기본 {stream} / 자동 {spacing} / 수동 {user}) · 세부유역 {basins}개 · 종단 Z {source}",
"{pipes} culverts ({stream} stream / {spacing} auto / {user} manual) · {basins} sub-basins · profile Z {source}",
],
B04_Surface_Basin_Edited: [
"편집 중 — [상세유역 분석]을 눌러 다시 나눕니다.",
"Edited — press [Detail basins] to re-split.",
],
B04_Surface_Basin_TooClose: [
"관끼리 최소 간격 {min}m 안에는 넣을 수 없습니다.",
"Culverts cannot be closer than {min}m.",
],
B04_Surface_Basin_Failed: [
"상세유역을 계산하지 못했습니다.",
"Failed to compute the sub-basins.",
],
B04_Surface_Basin_Saved: [
"관 매설 지점 {count}개를 저장했습니다.",
"Saved {count} culvert points.",
],
B04_Surface_Map_PlannedRouteEmpty: [
"B03에서 계획노선 파일을 올리면 표시됩니다.",
"Shown after the planned route file is uploaded in B03.",
+3 -1
View File
@@ -218,7 +218,9 @@
--map-upstream-toggle: #1d4ed8; /* 토글 버튼 색띠 */
--map-satellite-toggle: #64748b; /* 배경사진은 선 색이 없어 중립 회색 */
--map-pipe-orphan: #e5e7eb; /* 유역이 없는 배관 마커 */
--map-pipe-marker: rgba(249, 115, 22, 0.95); /* B04 기본 관 마커 */
--map-pipe-marker: rgba(249, 115, 22, 0.95); /* B04 기본 관 마커(세류 교차점) */
--map-pipe-auto: rgba(14, 165, 233, 0.95); /* 최대 간격 규칙으로 자동 보충한 관 */
--map-pipe-user: rgba(22, 163, 74, 0.95); /* 사용자가 넣거나 옮긴 관 */
/* 배수유역 오버레이(B04 지도) */
--map-halo: rgba(255, 255, 255, 0.9); /* 선·글자 뒤에 까는 흰 테두리 */