feat(B05): 1차 배수유역을 상류 세류망 기준으로 재정의 + 단계 검증 화면

1차 영역이 도로 전체 버퍼까지 포함해 도로 아래(하류)로 퍼지고, 세류선을 피처
단위로만 갈라 상류망이 통째로 누락되던 문제를 고친다.

- 1차 영역 = 도로 교차점 상류로 이어진 세류망만 반경 버퍼. 노선은 버퍼하지 않음.
  노선이 영역 밖으로 나간 길이(road_outside_m)를 재서 반경 판단 근거로 노출.
- 상류 판정을 연결망 기준으로 교체: unary_union 노딩 -> 도로에서 절단 ->
  끝점 그래프 -> 도로 교차 노드를 통과하지 않는 확산. T자로 붙은 지류와
  2단계 이상 이어진 지류까지 상류망으로 따라간다.
- 상하류 판정 표고를 최근접 등고선 정점에서 TIN 선형보간으로 교체
  (ElevationSampler). 최근접 정점은 오차가 등고선 간격만큼 나서 계곡 교차점이
  한 등고선 위로 잡히고 상류 조각이 전부 하류로 오판됐다.
- 격자 크기 자동 강등 삭제 - config 값을 그대로 쓴다. 셀 수가 많으면 경고만.
  자동 강등이 도로 굽기 두께 전제를 조용히 깨뜨렸다.
- TIN 삼각망을 격자 범위 + 여유로 클리핑. 결과 동일, 속도만 개선.
- 기본 반경 300m -> 50m (단계 검증용).

단계 검증 수단
- GET /drainage/primary-region : TIN/흐름 계산 없이 상류망/하류망/1차영역/격자만 반환
- 프론트 1차영역 버튼 : 상류망(굵은 파랑), 하류망(회색 파선), 1차 영역(초록 채움),
  해석 격자를 실제 셀 눈금으로 렌더. 켤 때마다 재요청한다.
- 클릭 시 storage/{project}/B05_wf2_Route/drainage/primary_region.geojson 저장

합성 검증(T자 지류 + 2단계 지류 + 하류 지류 + 고아 세류):
상류망 4조각 채택 / 하류망 3조각 / 미연결 1개 제외 - 전부 기대대로.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 17:44:53 +09:00
co-authored by Claude Fable 5
parent c1f139527d
commit e1b410c391
6 changed files with 685 additions and 79 deletions
+38
View File
@@ -296,6 +296,44 @@ export interface DrainageBasinResponse {
basins: DrainageBasin[];
}
/** 1차 배수유역 근거(단계 검증용). TIN·흐름 계산 없이 세류 상·하류 판정과 격자 범위만 준다. */
export interface DrainagePrimaryRegion {
status: string;
project_id: string;
route_id: number;
radius_m: number;
/** 도로와 만난 세류선의 상류측 = 1차 영역의 기준선. */
upstream_lines: Array<Array<[number, number]>>;
/** 교차했으나 하류로 판정해 제외한 조각. 판정이 맞는지 눈으로 대조하는 용도. */
downstream_lines: Array<Array<[number, number]>>;
/** 상·하류 어느 망에도 이어지지 않아 제외한 세류 조각 수. */
no_contact_count: number;
/** 노선이 1차 영역 밖으로 나간 길이(m). 크면 반경을 올려야 한다는 신호. */
road_outside_m: number;
/** 1차 영역(상류 세류망 버퍼 합집합)의 외곽 링 목록. */
region_rings: Array<Array<[number, number]>>;
grid: {
cell_m: number;
rows: number;
cols: number;
cells: number;
width_m: number;
height_m: number;
/** 격자 bbox 링. 화면은 여기에 rows×cols 간격으로 실제 셀을 그린다. */
bbox_lonlat: Array<[number, number]>;
};
/** 영구저장소에 남긴 검증용 GeoJSON 경로. */
saved_to: string | null;
}
export async function fetchDrainagePrimaryRegion(
projectId: string,
): Promise<DrainagePrimaryRegion> {
return requestJson<DrainagePrimaryRegion>(`/projects/${projectId}/drainage/primary-region`, {
method: "GET",
});
}
export async function fetchDrainageCandidates(
projectId: string,
): Promise<DrainageCandidateResponse> {
@@ -45,12 +45,12 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Flow import (
)
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import (
GridSpec,
PrimaryRegion,
build_contour_cloud,
build_grid_spec,
build_primary_region,
build_terrain_grid,
expand_grid_spec,
route_elevation_floor,
select_upstream_streams,
)
from config.config_system import (
DRAINAGE_DITCH_SAMPLE_M,
@@ -164,7 +164,44 @@ def build_drainage_watershed(
)
# ── ①~④ 격자 해석 (캐시 대상) ───────────────────────────────────────────────
# ── ①~② 1차 배수유역 (단계 검증 대상) ──────────────────────────────────────
def resolve_primary_region(
vertices: list[RouteVertex],
route_line: LineString,
contour_features: list[dict[str, Any]],
stream_features: list[dict[str, Any]],
) -> PrimaryRegion | None:
"""도로 교차 세류선(상류측)과 노선을 반경 버퍼한 1차 배수유역과 격자 범위를 정한다.
상·하류 판정에 쓸 등고선은 노선 주변만 있으면 된다(교차점이 전부 노선 위이므로).
도엽 전체를 읽으면 이 단계에서만 수십 초가 날아간다.
"""
floor = route_elevation_floor([vertex.z for vertex in vertices])
near_bounds = route_line.buffer(DRAINAGE_INITIAL_RADIUS_M * 2.0).bounds
cloud = build_contour_cloud(contour_features, floor, near_bounds)
if cloud.is_empty:
logger.warning("배수유역: 노선 주변에 등고선이 없어 1차 영역을 정할 수 없습니다.")
return None
return build_primary_region(
route_line, stream_features, cloud, DRAINAGE_INITIAL_RADIUS_M, DRAINAGE_GRID_SIZE_M
)
def preview_primary_region(
vertices: list[RouteVertex],
contour_features: list[dict[str, Any]],
stream_features: list[dict[str, Any]],
) -> PrimaryRegion | None:
"""단계 검증용 — TIN·흐름 계산 없이 1차 배수유역 근거만 뽑는다."""
if len(vertices) < 2:
return None
route_line = LineString([(vertex.x, vertex.y) for vertex in vertices])
return resolve_primary_region(vertices, route_line, contour_features, stream_features)
# ── ③~④ 격자 해석 (캐시 대상) ───────────────────────────────────────────────
def _solve_grid(
@@ -180,13 +217,21 @@ def _solve_grid(
logger.info("배수유역: 격자 캐시 재사용 (%s)", cache_path)
return cached
floor = route_elevation_floor([vertex.z for vertex in vertices])
cloud = build_contour_cloud(contour_features, floor)
region = resolve_primary_region(vertices, route_line, contour_features, stream_features)
if region is None:
return None
spec = region.spec
# TIN용 등고선은 격자가 확장될 여지까지 한 번에 읽어 두고, 회차마다 범위 안쪽만 골라 쓴다.
reach = DRAINAGE_EXPAND_STEP_M * DRAINAGE_MAX_EXPAND_ROUNDS
x_min, y_min, x_max, y_max = region.area.bounds
cloud = build_contour_cloud(
contour_features,
route_elevation_floor([vertex.z for vertex in vertices]),
(x_min - reach, y_min - reach, x_max + reach, y_max + reach),
)
if cloud.is_empty:
logger.warning("배수유역: 등고선이 없어 격자 해석을 건너뜁니다.")
logger.warning("배수유역: 1차 영역 안에 등고선이 없어 격자 해석을 건너뜁니다.")
return None
streams = select_upstream_streams(route_line, stream_features, cloud)
spec = build_grid_spec(route_line, streams, DRAINAGE_INITIAL_RADIUS_M, DRAINAGE_GRID_SIZE_M)
terrain = road = flow = None
for round_index in range(DRAINAGE_MAX_EXPAND_ROUNDS + 1):
@@ -19,7 +19,7 @@ from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any
import numpy as np
@@ -27,11 +27,12 @@ from scipy.interpolate import LinearNDInterpolator
from scipy.ndimage import distance_transform_edt
from scipy.spatial import cKDTree
from shapely import segmentize
from shapely.geometry import LineString, shape
from shapely.geometry import LineString, MultiPolygon, Polygon, shape
from shapely.ops import substring, unary_union
from skimage.morphology import reconstruction
from config.config_system import (
DRAINAGE_CONTOUR_CLIP_MARGIN_M,
DRAINAGE_CONTOUR_MARGIN_M,
DRAINAGE_CONTOUR_MIN_LENGTH_M,
DRAINAGE_CONTOUR_RESAMPLE_M,
@@ -144,18 +145,23 @@ def _iter_linestrings(geometry: Any) -> list[LineString]:
def build_contour_cloud(
contour_features: list[dict[str, Any]],
elevation_floor_m: float | None = None,
clip_bounds: tuple[float, float, float, float] | None = None,
) -> ContourCloud:
"""등고선 피처를 표고가 붙은 정점 구름으로 바꾼다.
`elevation_floor_m` 아래 등고선은 계획선 최저점보다 낮아 상류 기여가 불가능하므로
버린다. 길이가 짧은 파편도 노이즈로 보고 버리되, 임계 이상인 봉우리 폐합 등고선은
남긴다(봉우리 표고가 사라지면 그 일대 흐름 방향이 통째로 틀어진다).
`clip_bounds`(x_min, y_min, x_max, y_max)를 주면 그 밖 등고선은 읽지 않는다. 도엽
전체 등고선을 다 물고 가면 TIN 삼각망 비용만 커지고 결과는 같다.
"""
xs: list[np.ndarray] = []
ys: list[np.ndarray] = []
zs: list[np.ndarray] = []
dropped_low = 0
dropped_short = 0
dropped_outside = 0
for feature in contour_features:
geometry = feature.get("geometry")
if not geometry:
@@ -170,6 +176,9 @@ def build_contour_cloud(
parsed = shape(geometry)
except Exception: # noqa: BLE001 - 손상된 피처는 건너뛴다
continue
if clip_bounds is not None and _outside_bounds(parsed.bounds, clip_bounds):
dropped_outside += 1
continue
for line in _iter_linestrings(parsed):
if line.length < DRAINAGE_CONTOUR_MIN_LENGTH_M:
dropped_short += 1
@@ -182,22 +191,30 @@ def build_contour_cloud(
zs.append(np.full(coords.shape[0], elevation, dtype=np.float64))
if not xs:
logger.warning(
"배수유역: 사용할 등고선이 없습니다(저지대 %d, 파편 %d 제외).",
"배수유역: 사용할 등고선이 없습니다(저지대 %d, 파편 %d, 범위밖 %d 제외).",
dropped_low,
dropped_short,
dropped_outside,
)
return ContourCloud(np.zeros((0, 2)), np.zeros(0))
xy = np.column_stack((np.concatenate(xs), np.concatenate(ys)))
z = np.concatenate(zs)
logger.info(
"배수유역: 등고선 정점 %d개 (저지대 %d, 파편 %d 제외)",
"배수유역: 등고선 정점 %d개 (저지대 %d, 파편 %d, 범위밖 %d 제외)",
xy.shape[0],
dropped_low,
dropped_short,
dropped_outside,
)
return ContourCloud(xy, z)
def _outside_bounds(
bounds: tuple[float, float, float, float], clip: tuple[float, float, float, float]
) -> bool:
return bounds[2] < clip[0] or bounds[0] > clip[2] or bounds[3] < clip[1] or bounds[1] > clip[3]
# ── ② 세류선 상류측만 남기기 ────────────────────────────────────────────────
@@ -206,14 +223,36 @@ def select_upstream_streams(
stream_features: list[dict[str, Any]],
cloud: ContourCloud,
) -> list[LineString]:
"""노선과 교차하는 세류선을 교차점에서 잘라 상류(고지대)측만 돌려준다.
"""도로 교차점 기준 상류측으로 이어진 세류 연결망 전체를 돌려준다."""
return split_streams_at_road(route_line, stream_features, cloud).upstream
노선과 만나지 않는 세류선은 판단 근거가 없으므로 그대로 남긴다 — 어차피 격자 해석에서
도로에 물이 닿지 않으면 비활성 처리된다. 상·하류 판정은 가장 가까운 등고선 정점의
표고 평균으로 한다(TIN은 이 시점에 아직 없다).
@dataclass
class StreamSplit:
"""세류망을 도로 교차점에서 상·하류로 가른 결과. 검증 화면이 그대로 그린다."""
upstream: list[LineString] = field(default_factory=list) # 채택 — 1차 영역의 기준
downstream: list[LineString] = field(default_factory=list) # 도로 아래로 이어진 망
no_contact: int = 0 # 어느 쪽에도 이어지지 않아 제외한 조각 수
def split_streams_at_road(
route_line: LineString,
stream_features: list[dict[str, Any]],
cloud: ContourCloud,
) -> StreamSplit:
"""세류망을 도로에서 끊고, 교차점 상류측으로 **이어진 망 전체**를 채택한다.
도엽 하천중심선은 피처가 잘게 쪼개져 있고 지류가 본류 중간에 T자로 붙는다. 그래서
피처 단위로 보면 상류망이 통째로 빠진다. 순서를 이렇게 잡는다:
① 세류선끼리 `unary_union`으로 노딩 — 중간에서 만나는 지류도 연결로 인식된다
② 도로 교차점에서 한 번 더 잘라 상·하류 조각을 물리적으로 분리한다
③ 끝점 그래프를 만들고, **도로 교차 노드는 통과하지 못하게** 막는다
④ 도로에 접한 조각을 교차점 표고와 비교해 상·하류 씨앗으로 정한다
⑤ 씨앗에서 퍼뜨려 이어진 망 전체를 채택 — 도로를 넘어가지 못하므로 상·하류가 섞이지 않는다
"""
tree = cKDTree(cloud.xy) if not cloud.is_empty else None
kept: list[LineString] = []
lines: list[LineString] = []
for feature in stream_features:
geometry = feature.get("geometry")
if not geometry:
@@ -222,46 +261,148 @@ def select_upstream_streams(
parsed = shape(geometry)
except Exception: # noqa: BLE001
continue
for line in _iter_linestrings(parsed):
if line.is_empty or line.length <= 0:
continue
if not line.intersects(route_line):
kept.append(line)
continue
kept.extend(_upstream_parts(line, route_line, tree, cloud))
return kept
lines.extend(line for line in _iter_linestrings(parsed) if line.length > 0)
if not lines:
return StreamSplit()
pieces, crossing_nodes = _cut_network_at_road(lines, route_line)
if not pieces:
return StreamSplit()
def _upstream_parts(
line: LineString,
route_line: LineString,
tree: cKDTree | None,
cloud: ContourCloud,
) -> list[LineString]:
"""세류선을 노선 교차점에서 잘라 평균 표고가 높은 조각만 남긴다."""
cuts = sorted(
{
line.project(point)
for point in _intersection_points(line.intersection(route_line))
if 0.0 < line.project(point) < line.length
}
node_edges: dict[tuple[float, float], list[int]] = {}
ends: list[tuple[tuple[float, float], tuple[float, float]]] = []
for index, piece in enumerate(pieces):
head = _node_key(*piece.coords[0])
tail = _node_key(*piece.coords[-1])
ends.append((head, tail))
node_edges.setdefault(head, []).append(index)
node_edges.setdefault(tail, []).append(index)
sampler = ElevationSampler(cloud)
upper_seeds: set[int] = set()
lower_seeds: set[int] = set()
for index, piece in enumerate(pieces):
touching = [node for node in ends[index] if node in crossing_nodes]
if not touching:
continue
crossing_z = float(np.min(sampler.at(np.array(touching, dtype=np.float64))))
if _mean_elevation(piece, sampler) > crossing_z:
upper_seeds.add(index)
else:
lower_seeds.add(index)
upstream = _spread_network(upper_seeds, ends, node_edges, crossing_nodes)
downstream = _spread_network(lower_seeds, ends, node_edges, crossing_nodes) - upstream
logger.info(
"배수유역: 세류 조각 %d개 → 상류망 %d개 채택 / 하류망 %d개 · 미연결 %d개 제외",
len(pieces),
len(upstream),
len(downstream),
len(pieces) - len(upstream) - len(downstream),
)
if not cuts:
return [line]
bounds = [0.0, *cuts, line.length]
parts: list[tuple[float, LineString]] = []
for start, end in zip(bounds, bounds[1:]):
if end - start < 1.0:
return StreamSplit(
upstream=[pieces[index] for index in sorted(upstream)],
downstream=[pieces[index] for index in sorted(downstream)],
no_contact=len(pieces) - len(upstream) - len(downstream),
)
def _cut_network_at_road(
lines: list[LineString], route_line: LineString
) -> tuple[list[LineString], set[tuple[float, float]]]:
"""세류망을 노딩한 뒤 도로 교차점에서 자르고, 그 교차 노드를 함께 돌려준다."""
noded = unary_union(lines)
pieces: list[LineString] = []
crossing_nodes: set[tuple[float, float]] = set()
for piece in _iter_linestrings(noded):
if not piece.intersects(route_line):
pieces.append(piece)
continue
piece = _substring(line, start, end)
if piece is None:
hits = _intersection_points(piece.intersection(route_line))
positions = sorted(
{
position
for position in (piece.project(point) for point in hits)
if 0.0 < position < piece.length
}
)
for point in hits:
crossing_nodes.add(_node_key(point.x, point.y))
if not positions:
# 끝점이 도로에 닿은 경우 — 자를 필요는 없고 그 끝점이 곧 교차 노드다.
pieces.append(piece)
continue
parts.append((_mean_elevation(piece, tree, cloud), piece))
if not parts:
return []
highest = max(value for value, _ in parts)
# 최상류 조각과 표고가 비슷한(1m 이내) 조각까지 상류로 본다. 나머지는 하류이므로 버린다.
return [piece for value, piece in parts if highest - value <= 1.0]
bounds = [0.0, *positions, piece.length]
for start, end in zip(bounds, bounds[1:]):
if end - start <= 0:
continue
cut = _substring(piece, start, end)
if cut is not None:
pieces.append(cut)
return pieces, crossing_nodes
def _spread_network(
seeds: set[int],
ends: list[tuple[tuple[float, float], tuple[float, float]]],
node_edges: dict[tuple[float, float], list[int]],
blocked: set[tuple[float, float]],
) -> set[int]:
"""씨앗 조각에서 끝점을 타고 퍼진다. 도로 교차 노드는 통과하지 않는다."""
reached = set(seeds)
queue = list(seeds)
while queue:
index = queue.pop()
for node in ends[index]:
if node in blocked:
continue
for neighbour in node_edges.get(node, ()):
if neighbour not in reached:
reached.add(neighbour)
queue.append(neighbour)
return reached
def _node_key(x: float, y: float) -> tuple[float, float]:
"""끝점 일치 판정용 좌표 키. 노딩 후에도 부동소수 오차가 남아 mm로 반올림한다."""
return (round(float(x), 3), round(float(y), 3))
class ElevationSampler:
"""등고선 구름에서 임의 지점 표고를 읽는다 — 상·하류 판정 전용.
최근접 등고선 정점만 쓰면 오차가 등고선 간격(주곡선 5m)만큼 나서, 계곡 교차점 표고가
실제보다 한 등고선 위로 잡히고 상류 조각이 통째로 하류로 오판된다. TIN 선형보간을
1차로 쓰고, TIN 밖(볼록껍질 외부)만 최근접 정점으로 메운다.
"""
def __init__(self, cloud: ContourCloud) -> None:
self._z = cloud.z
if cloud.is_empty:
self._interpolator = None
self._tree = None
return
self._interpolator = LinearNDInterpolator(cloud.xy, cloud.z)
self._tree = cKDTree(cloud.xy)
def at(self, xy: np.ndarray) -> np.ndarray:
"""(N, 2) 좌표의 표고 (N,)."""
if self._interpolator is None or self._tree is None:
return np.zeros(xy.shape[0])
values = np.asarray(self._interpolator(xy), dtype=np.float64)
missing = ~np.isfinite(values)
if missing.any():
_, indices = self._tree.query(xy[missing])
values[missing] = self._z[indices]
return values
def _point_elevation(point: Any, tree: cKDTree | None, cloud: ContourCloud) -> float:
"""가장 가까운 등고선 정점의 표고. TIN이 없는 단계의 근사 표고다."""
if tree is None:
return 0.0
_, index = tree.query([[point.x, point.y]])
return float(cloud.z[index[0]])
def _intersection_points(geometry: Any) -> list[Any]:
@@ -287,46 +428,95 @@ def _substring(line: LineString, start: float, end: float) -> LineString | None:
return piece
def _mean_elevation(line: LineString, tree: cKDTree | None, cloud: ContourCloud) -> float:
if tree is None:
return 0.0
def _mean_elevation(line: LineString, sampler: ElevationSampler) -> float:
"""선을 10m 간격으로 훑은 평균 표고."""
samples = max(2, int(line.length // 10.0) + 1)
positions = np.linspace(0.0, line.length, samples)
points = np.array([list(line.interpolate(position).coords)[0] for position in positions])
_, indices = tree.query(points)
return float(np.mean(cloud.z[indices]))
return float(np.mean(sampler.at(points)))
# ── ③ 격자 범위 ─────────────────────────────────────────────────────────────
def build_grid_spec(
@dataclass
class PrimaryRegion:
"""1차 배수유역 — 격자 범위의 근거. 검증 화면이 이 내용을 그대로 그린다."""
split: StreamSplit
# 상류 세류망을 반경 버퍼해 합친 영역. 노선은 버퍼하지 않는다.
area: Polygon | MultiPolygon | None
spec: GridSpec
radius_m: float
# 1차 영역 밖으로 나간 노선 길이(m). 그 구간 사면은 해석에서 빠진다는 경고 지표.
road_outside_m: float = 0.0
def build_primary_region(
route_line: LineString,
streams: list[LineString],
stream_features: list[dict[str, Any]],
cloud: ContourCloud,
radius_m: float,
cell_m: float = DRAINAGE_GRID_SIZE_M,
) -> GridSpec:
"""노선과 상류 세류선을 반경 버퍼한 범위의 bbox로 격자를 잡는다.
) -> PrimaryRegion:
"""**상류 세류망만** 반경 버퍼한 범위 = 1차 배수유역, 그 bbox = 해석 격자.
셀 수가 상한을 넘으면 셀 크기를 자동으로 키워 맞춘다(메모리 보호). 실제 유역 모양은
격자가 아니라 흐름 해석이 정한다 — 여기서는 넉넉한 사각 범위만 확보하면 된다.
노선은 버퍼하지 않는다(2026-07-31 사용자 지시). 1차 영역의 기준은 도로 교차점 상류로
이어진 세류선 그 자체이며, 도로를 버퍼하면 도로 아래쪽(하류)까지 영역이 퍼져 의미가 없다.
노선이 이 영역 밖으로 나가는 길이는 따로 재서 남긴다 — 그 구간은 도로 셀이 격자에
없어 유역이 잡히지 않으므로 반경을 올릴지 판단하는 근거가 된다.
"""
geometries = [route_line.buffer(radius_m)]
geometries.extend(line.buffer(radius_m) for line in streams)
x_min, y_min, x_max, y_max = unary_union(geometries).bounds
return _spec_from_bounds(x_min, y_min, x_max, y_max, cell_m)
split = split_streams_at_road(route_line, stream_features, cloud)
geometries = [line.buffer(radius_m) for line in split.upstream]
if not geometries:
logger.warning("배수유역: 상류 세류망이 없어 노선 버퍼로 대체합니다.")
geometries = [route_line.buffer(radius_m)]
area = unary_union(geometries)
x_min, y_min, x_max, y_max = area.bounds
spec = _spec_from_bounds(x_min, y_min, x_max, y_max, cell_m)
outside = route_line.difference(area)
road_outside_m = float(outside.length) if not outside.is_empty else 0.0
logger.info(
"배수유역: 1차 영역 %.0f㎡, 범위 %.0fm × %.0fm, 격자 %d×%d (%.2fm), 노선 이탈 %.0fm/%.0fm",
area.area,
x_max - x_min,
y_max - y_min,
spec.n_rows,
spec.n_cols,
spec.cell_m,
road_outside_m,
route_line.length,
)
return PrimaryRegion(
split=split, area=area, spec=spec, radius_m=radius_m, road_outside_m=road_outside_m
)
def _spec_from_bounds(
x_min: float, y_min: float, x_max: float, y_max: float, cell_m: float
) -> GridSpec:
"""격자 크기는 절대 자동으로 바꾸지 않는다 — config 값이 그대로 쓰인다(사용자 지시).
셀 수가 많으면 경고만 남기고 그대로 진행한다. 느리면 `DRAINAGE_GRID_SIZE_M`을 사용자가
직접 올린다. 자동 강등은 도로 굽기 두께·정밀도 전제를 조용히 깨뜨려서 금지한다.
"""
width = max(x_max - x_min, cell_m)
height = max(y_max - y_min, cell_m)
while (width / cell_m) * (height / cell_m) > DRAINAGE_MAX_GRID_CELLS:
cell_m *= 2.0
logger.warning("배수유역: 셀 수 상한 초과 — 격자 크기를 %.1fm로 키웁니다.", cell_m)
n_cols = int(math.ceil(width / cell_m))
n_rows = int(math.ceil(height / cell_m))
if n_cols * n_rows > DRAINAGE_MAX_GRID_CELLS:
logger.warning(
"배수유역: 격자 %d×%d = %d셀 (%.0fm × %.0fm, 셀 %.1fm) — 권장 상한 %d셀 초과. "
"그대로 진행합니다. 느리면 DRAINAGE_GRID_SIZE_M을 올리세요.",
n_rows,
n_cols,
n_rows * n_cols,
width,
height,
cell_m,
DRAINAGE_MAX_GRID_CELLS,
)
return GridSpec(
x_min=x_min, y_max=y_min + n_rows * cell_m, cell_m=cell_m, n_rows=n_rows, n_cols=n_cols
)
@@ -345,11 +535,26 @@ def expand_grid_spec(spec: GridSpec, sides: dict[str, bool], step_m: float) -> G
def interpolate_elevation(spec: GridSpec, cloud: ContourCloud) -> np.ndarray:
"""등고선 정점 Delaunay TIN으로 셀 표고를 선형보간한다. 외부는 NaN."""
"""등고선 정점 Delaunay TIN으로 셀 표고를 선형보간한다. 외부는 NaN.
삼각망 비용은 정점 수에 비례한다. 격자 밖 정점으로 만든 삼각형은 어차피 쓰이지 않으므로
격자 범위 + 여유만큼만 남기고 잘라낸다 — 결과 표고는 그대로고 속도만 는다.
"""
surface = np.full((spec.n_rows, spec.n_cols), np.nan, dtype=np.float32)
if cloud.is_empty:
return surface
interpolator = LinearNDInterpolator(cloud.xy, cloud.z)
margin = DRAINAGE_CONTOUR_CLIP_MARGIN_M
inside = (
(cloud.xy[:, 0] >= spec.x_min - margin)
& (cloud.xy[:, 0] <= spec.x_min + spec.n_cols * spec.cell_m + margin)
& (cloud.xy[:, 1] >= spec.y_max - spec.n_rows * spec.cell_m - margin)
& (cloud.xy[:, 1] <= spec.y_max + margin)
)
if inside.sum() < 3:
logger.warning("배수유역: 격자 범위 안에 등고선 정점이 없습니다.")
return surface
logger.info("배수유역: TIN 정점 %d개 사용 (전체 %d개)", int(inside.sum()), cloud.xy.shape[0])
interpolator = LinearNDInterpolator(cloud.xy[inside], cloud.z[inside])
xs = spec.cell_centers_x()
ys = spec.cell_centers_y()
# 행 묶음 단위로 평가해 (행×열) 좌표 배열을 한 번에 들고 있지 않게 한다.
+130 -2
View File
@@ -22,7 +22,10 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import (
build_route_vertices,
propose_structure_stations,
)
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Basin import build_drainage_watershed
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Basin import (
build_drainage_watershed,
preview_primary_region,
)
from B05_wf2_Route.B05_wf2_Route_Repository import (
get_latest_route,
get_route_points,
@@ -30,7 +33,11 @@ from B05_wf2_Route.B05_wf2_Route_Repository import (
)
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
from config.config_system import DRAINAGE_CACHE_DIRNAME, DRAINAGE_CACHE_FILENAME
from config.config_system import (
DRAINAGE_CACHE_DIRNAME,
DRAINAGE_CACHE_FILENAME,
DRAINAGE_REGION_FILENAME,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B05 Route Drainage"])
@@ -156,6 +163,7 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse:
"vertices": vertices,
"streams": streams,
"contours": contour_features,
"stored_path": stored_path,
"cache_path": _cache_path(stored_path),
"to_lonlat": lambda x, y: to_lonlat_transformer.transform(x, y),
}
@@ -177,6 +185,126 @@ async def get_structure_candidates(project_id: UUID) -> dict[str, Any] | JSONRes
}
@router.get("/{project_id}/drainage/primary-region", response_model=None)
async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse:
"""1차 배수유역 근거를 돌려준다 — 단계 검증용, TIN·흐름 계산은 하지 않는다.
도로 교차점 상류로 이어진 세류망, 제외된 하류망, 그 상류망을 반경 버퍼한 1차 영역,
그 bbox로 잡은 격자 정보를 함께 준다. 같은 내용을 영구저장소에 GeoJSON으로도 남겨
QGIS 등으로 직접 열어 대조할 수 있게 한다.
"""
prepared = await _prepare(project_id)
if isinstance(prepared, JSONResponse):
return prepared
region = await asyncio.to_thread(
preview_primary_region,
prepared["vertices"],
prepared["contours"],
prepared["streams"],
)
if region is None:
return JSONResponse(
status_code=400,
content={"status": "error", "message": "1차 배수유역을 정할 등고선이 없습니다."},
)
to_lonlat = prepared["to_lonlat"]
spec = region.spec
payload = {
"status": "success",
"project_id": str(project_id),
"route_id": prepared["route_id"],
"radius_m": region.radius_m,
# 채택된 상류 세류망 = 1차 영역의 기준선.
"upstream_lines": [_line_lonlat(line, to_lonlat) for line in region.split.upstream],
# 도로 아래로 이어진 하류망 — 판정이 맞는지 눈으로 대조하기 위해 함께 준다.
"downstream_lines": [_line_lonlat(line, to_lonlat) for line in region.split.downstream],
"no_contact_count": region.split.no_contact,
# 노선이 1차 영역 밖으로 나간 길이(m). 크면 반경을 올려야 한다는 신호.
"road_outside_m": round(region.road_outside_m, 1),
# 1차 영역(버퍼 합집합) 외곽 링 목록.
"region_rings": _polygon_rings(region.area, to_lonlat),
"grid": {
"cell_m": spec.cell_m,
"rows": spec.n_rows,
"cols": spec.n_cols,
"cells": spec.size,
"width_m": round(spec.n_cols * spec.cell_m, 1),
"height_m": round(spec.n_rows * spec.cell_m, 1),
# 격자 bbox 링(닫힌 사각형). 프론트가 여기에 cell_m 간격으로 실제 셀을 그린다.
"bbox_lonlat": _grid_bbox_lonlat(spec, to_lonlat),
},
}
payload["saved_to"] = _save_region_geojson(prepared["stored_path"], payload)
return payload
def _save_region_geojson(stored_path: str, payload: dict[str, Any]) -> str | None:
"""1차 영역 검증 산출물을 영구저장소에 GeoJSON(WGS84)으로 남긴다."""
features: list[dict[str, Any]] = []
for index, ring in enumerate(payload["region_rings"]):
features.append(_geojson_feature("primary_region", index, "Polygon", [ring]))
for index, line in enumerate(payload["upstream_lines"]):
features.append(_geojson_feature("upstream", index, "LineString", line))
for index, line in enumerate(payload["downstream_lines"]):
features.append(_geojson_feature("downstream", index, "LineString", line))
features.append(_geojson_feature("grid_bbox", 0, "Polygon", [payload["grid"]["bbox_lonlat"]]))
document = {
"type": "FeatureCollection",
"crs": {"type": "name", "properties": {"name": "urn:ogc:def:crs:OGC:1.3:CRS84"}},
"properties": {
"radius_m": payload["radius_m"],
"road_outside_m": payload["road_outside_m"],
"no_contact_count": payload["no_contact_count"],
"grid": {key: value for key, value in payload["grid"].items() if key != "bbox_lonlat"},
},
"features": features,
}
target = (
Path(resolve_stored_project_path(stored_path))
/ "B05_wf2_Route"
/ DRAINAGE_CACHE_DIRNAME
/ DRAINAGE_REGION_FILENAME
)
try:
target.parent.mkdir(parents=True, exist_ok=True)
with target.open("w", encoding="utf-8") as file:
json.dump(document, file, ensure_ascii=False)
except OSError:
logger.warning("배수유역: 1차 영역 GeoJSON을 저장하지 못했습니다 (%s).", target)
return None
logger.info("배수유역: 1차 영역 GeoJSON 저장 — %s (피처 %d개)", target, len(features))
return str(target)
def _geojson_feature(kind: str, index: int, geom_type: str, coordinates: Any) -> dict[str, Any]:
return {
"type": "Feature",
"properties": {"kind": kind, "index": index},
"geometry": {"type": geom_type, "coordinates": coordinates},
}
def _line_lonlat(line: Any, to_lonlat: Any) -> list[list[float]]:
return [list(to_lonlat(x, y)) for x, y in line.coords]
def _polygon_rings(geometry: Any, to_lonlat: Any) -> list[list[list[float]]]:
"""폴리곤/멀티폴리곤의 외곽 링만 뽑아 lonlat으로 바꾼다."""
if geometry is None or geometry.is_empty:
return []
parts = geometry.geoms if geometry.geom_type == "MultiPolygon" else [geometry]
return [[list(to_lonlat(x, y)) for x, y in part.exterior.coords] for part in parts]
def _grid_bbox_lonlat(spec: Any, to_lonlat: Any) -> list[list[float]]:
x_min = spec.x_min
x_max = spec.x_min + spec.n_cols * spec.cell_m
y_max = spec.y_max
y_min = spec.y_max - spec.n_rows * spec.cell_m
corners = ((x_min, y_min), (x_min, y_max), (x_max, y_max), (x_max, y_min), (x_min, y_min))
return [list(to_lonlat(x, y)) for x, y in corners]
@router.post("/{project_id}/drainage/basins", response_model=None)
async def post_drainage_basins(
project_id: UUID,
@@ -21,7 +21,9 @@ import {
} from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender";
import {
fetchDrainageBasins,
fetchDrainagePrimaryRegion,
type DrainageBasin,
type DrainagePrimaryRegion,
type RoutePoint,
} from "./B05_wf2_Route_Api_Fetch";
import { createPipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes";
@@ -107,7 +109,16 @@ export function createDrainagePanel(): DrainagePanel {
autoButton.type = "button";
autoButton.className = "b05-drainage__analyze b05-drainage__tool";
autoButton.textContent = "자동 제안";
header.append(analyzeButton, editButton, deleteButton, autoButton);
// 1차 영역 확인 — TIN·흐름 계산 없이 세류 상·하류 판정과 격자 범위만 그려 눈으로 검증한다.
const regionButton = document.createElement("button");
regionButton.type = "button";
regionButton.className = "b05-drainage__analyze b05-drainage__tool";
regionButton.textContent = "1차 영역";
regionButton.title =
"도로와 만나는 세류선의 상류측(파랑 굵은 선)·하류측(회색 파선)과 " +
"그 반경 버퍼로 잡은 1차 배수유역, 해석 격자 범위를 표시합니다.";
regionButton.setAttribute("aria-pressed", "false");
header.append(analyzeButton, editButton, deleteButton, autoButton, regionButton);
const viewport = document.createElement("div");
viewport.className = "b05-drainage__viewport";
@@ -145,6 +156,9 @@ export function createDrainagePanel(): DrainagePanel {
// 2차 전체 배수유역 외곽선 = 분수령. 별도 "능선" 레이어를 두지 않고 이 선 하나로 표시한다
// (전체 유역 외곽선과 능선이 같은 선이므로 — 2026-07-31 사용자 지시).
let mainBoundary: Array<[number, number]> = [];
// 1차 영역 검증 오버레이. null이면 표시하지 않는다.
let primaryRegion: DrainagePrimaryRegion | null = null;
let showRegion = false;
let scale = 1;
let offsetX = 0;
let offsetY = 0;
@@ -213,6 +227,8 @@ export function createDrainagePanel(): DrainagePanel {
});
// 전체 유역 외곽선 = 분수령(능선). 세부유역 경계와 구분되게 파선 한 겹만 얹는다.
if (mainBoundary.length > 2) drawRidgeRing(context, mainBoundary, normalizer, view);
// 1차 영역 검증 오버레이는 채움 위·등고선 아래에 깐다.
if (showRegion && primaryRegion) drawPrimaryRegion(context, normalizer, view);
}
// 등고선을 얇게 깔고 세류를 그 위에, 노선을 맨 위에 둔다.
DRAINAGE_LAYERS.forEach((layer) => {
@@ -234,6 +250,121 @@ export function createDrainagePanel(): DrainagePanel {
updateImageTransform();
}
/** lon/lat 폴리라인을 화면 좌표로 옮겨 한 줄 그린다(1차 영역 오버레이 전용). */
function strokeLonLat(
context: CanvasRenderingContext2D,
line: ReadonlyArray<readonly [number, number]>,
map: Normalizer,
view: ViewState,
): void {
if (line.length < 2) return;
const ax = view.mapRect.width * view.scale;
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
const ay = view.mapRect.height * view.scale;
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
context.beginPath();
line.forEach(([lon, lat], index) => {
const x = ((lon - map.lonMin) / map.lonRange) * ax + bx;
const y = (1 - (lat - map.latMin) / map.latRange) * ay + by;
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.stroke();
}
/** 해석 격자를 실제 셀 눈금으로 그린다.
*
* 셀 간격이 화면에서 너무 촘촘하면(2px 미만) 눈금이 뭉개져 회색 덩어리가 되므로,
* 그때는 테두리만 남기고 "확대하면 셀이 보인다"는 상태를 유지한다. */
function drawGridCells(
context: CanvasRenderingContext2D,
map: Normalizer,
view: ViewState,
region: DrainagePrimaryRegion,
): void {
const ring = region.grid.bbox_lonlat;
if (ring.length < 4) return;
const lons = ring.map(([lon]) => lon);
const lats = ring.map(([, lat]) => lat);
const lonMin = Math.min(...lons);
const lonMax = Math.max(...lons);
const latMin = Math.min(...lats);
const latMax = Math.max(...lats);
const ax = view.mapRect.width * view.scale;
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
const ay = view.mapRect.height * view.scale;
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
const toX = (lon: number): number => ((lon - map.lonMin) / map.lonRange) * ax + bx;
const toY = (lat: number): number => (1 - (lat - map.latMin) / map.latRange) * ay + by;
const left = toX(lonMin);
const right = toX(lonMax);
const top = toY(latMax);
const bottom = toY(latMin);
context.save();
context.setLineDash([]);
// 셀 눈금 — 열/행 수로 나눠 실제 셀 경계를 그대로 찍는다.
const cellWidthPx = Math.abs(right - left) / Math.max(region.grid.cols, 1);
const cellHeightPx = Math.abs(bottom - top) / Math.max(region.grid.rows, 1);
if (Math.min(cellWidthPx, cellHeightPx) >= 2) {
context.lineWidth = 0.5;
context.strokeStyle = "rgba(120, 113, 108, 0.35)";
context.beginPath();
for (let col = 0; col <= region.grid.cols; col += 1) {
const x = left + (right - left) * (col / region.grid.cols);
if (x < -50 || x > view.width + 50) continue;
context.moveTo(x, top);
context.lineTo(x, bottom);
}
for (let row = 0; row <= region.grid.rows; row += 1) {
const y = top + (bottom - top) * (row / region.grid.rows);
if (y < -50 || y > view.height + 50) continue;
context.moveTo(left, y);
context.lineTo(right, y);
}
context.stroke();
}
// 격자 전체 테두리는 항상 그린다.
context.setLineDash([10, 6]);
context.lineWidth = 1.5;
context.strokeStyle = "rgba(120, 113, 108, 0.9)";
context.strokeRect(left, top, right - left, bottom - top);
context.restore();
}
/** 1차 배수유역 근거를 겹쳐 그린다 — 단계 검증용. */
function drawPrimaryRegion(
context: CanvasRenderingContext2D,
map: Normalizer,
view: ViewState,
): void {
const region = primaryRegion;
if (!region) return;
context.save();
// ① 해석 격자 — bbox 테두리 + 실제 셀 눈금.
drawGridCells(context, map, view, region);
// ② 1차 배수유역 = 상류 세류망의 반경 버퍼 합집합.
context.setLineDash([]);
context.lineWidth = 2;
context.strokeStyle = "rgba(5, 150, 105, 0.95)";
context.fillStyle = "rgba(16, 185, 129, 0.12)";
region.region_rings.forEach((ring) => {
strokeLonLat(context, ring, map, view);
context.fill();
});
// ③ 도로 아래로 이어진 하류망 — 판정이 맞는지 대조하도록 회색 파선으로 남긴다.
context.setLineDash([6, 5]);
context.lineWidth = 2;
context.strokeStyle = "rgba(120, 113, 108, 0.85)";
region.downstream_lines.forEach((line) => strokeLonLat(context, line, map, view));
// ④ 채택된 상류망 = 1차 영역의 기준선. 가장 굵게, 맨 위에.
context.setLineDash([]);
context.lineWidth = 4;
context.strokeStyle = "rgba(29, 78, 216, 0.95)";
region.upstream_lines.forEach((line) => strokeLonLat(context, line, map, view));
context.restore();
}
/** 현재 프레임 뷰 상태 (draw()와 동일 계산 — 포인터 히트 판정용). */
function currentView(): ViewState {
const rect = viewport.getBoundingClientRect();
@@ -351,6 +482,53 @@ export function createDrainagePanel(): DrainagePanel {
void analyze(true);
});
/** 1차 영역 근거를 불러와 겹쳐 그린다.
*
* 켤 때는 **항상 다시 요청한다** — config(반경·격자)를 바꾸고 서버를 재시작한 뒤
* 눌렀는데 캐시된 예전 결과가 나오면 검증이 성립하지 않는다. 끌 때만 요청 없이 숨긴다. */
async function toggleRegion(): Promise<void> {
if (!projectId) return;
if (showRegion) {
showRegion = false;
regionButton.classList.remove("is-active");
regionButton.setAttribute("aria-pressed", "false");
status.hidden = true;
scheduleDraw();
return;
}
regionButton.disabled = true;
status.hidden = false;
status.textContent = "1차 배수유역을 확인하는 중…";
try {
primaryRegion = await fetchDrainagePrimaryRegion(projectId);
showRegion = true;
regionButton.classList.add("is-active");
regionButton.setAttribute("aria-pressed", "true");
status.textContent = regionSummary(primaryRegion);
} catch (error) {
status.textContent =
error instanceof Error ? error.message : "1차 배수유역을 확인하지 못했습니다.";
} finally {
regionButton.disabled = false;
scheduleDraw();
}
}
/** 상태줄에 띄울 1차 영역 요약 — 격자 셀 수를 보고 격자 크기를 조정할 근거가 된다. */
function regionSummary(region: DrainagePrimaryRegion): string {
const cells = region.grid.cells.toLocaleString();
const outside =
region.road_outside_m > 0 ? ` · 노선 이탈 ${Math.round(region.road_outside_m)}m` : "";
return (
`1차 영역(반경 ${region.radius_m}m): 상류망 ${region.upstream_lines.length}조각 채택 / ` +
`하류망 ${region.downstream_lines.length}조각·미연결 ${region.no_contact_count}개 제외 · ` +
`격자 ${region.grid.width_m}×${region.grid.height_m}m, ` +
`${region.grid.cell_m}m 셀 ${cells}${outside}`
);
}
regionButton.addEventListener("click", () => void toggleRegion());
/** 노선 전체가 보이도록 배율·중심을 맞춘다. 노선이 없으면 도엽 전체를 그대로 보여준다. */
function fitToRoute(): void {
scale = 1;
@@ -388,6 +566,11 @@ export function createDrainagePanel(): DrainagePanel {
const sequence = ++loadSequence;
meta = null;
preparedLayers.clear();
// 1차 영역은 프로젝트·노선에 종속이므로 새로 불러올 때 버린다.
primaryRegion = null;
showRegion = false;
regionButton.classList.remove("is-active");
regionButton.setAttribute("aria-pressed", "false");
routeLayer = null;
backgroundImage.removeAttribute("src");
status.hidden = false;
+10 -3
View File
@@ -242,13 +242,15 @@ SKELETON_NODE_SPACING_M = float(os.getenv("SKELETON_NODE_SPACING_M", "10.0"))
# ─────────────────────────────────────────────────────────────────────────
# 해석 격자 한 변(m). 작을수록 정밀하나 셀 수가 제곱으로 늘어난다.
DRAINAGE_GRID_SIZE_M = float(os.getenv("DRAINAGE_GRID_SIZE_M", "1.0"))
# 1차 배수유역 반경(m). 정리된 세류선과 노선을 이 반경으로 버퍼해 초기 해석 범위를 잡는다.
DRAINAGE_INITIAL_RADIUS_M = float(os.getenv("DRAINAGE_INITIAL_RADIUS_M", "300.0"))
# 1차 배수유역 반경(m). 도로 교차점 상류로 이어진 세류망을 이 반경으로 버퍼한 범위가
# 1차 영역이며 그 bbox가 해석 격자다. 노선은 버퍼하지 않는다(2026-07-31 사용자 지시).
DRAINAGE_INITIAL_RADIUS_M = float(os.getenv("DRAINAGE_INITIAL_RADIUS_M", "50.0"))
# 활성 셀이 격자 최외곽에 닿았을 때 한 번에 넓히는 폭(m).
DRAINAGE_EXPAND_STEP_M = float(os.getenv("DRAINAGE_EXPAND_STEP_M", "200.0"))
# 확장 반복 상한. 경계 링이 전부 비활성이 되면 그 전에 스스로 멈춘다(안전핀).
DRAINAGE_MAX_EXPAND_ROUNDS = int(os.getenv("DRAINAGE_MAX_EXPAND_ROUNDS", "6"))
# 격자 셀 수 상한. 초과하면 셀 크기 자동으로 키워 맞춘다(메모리 보호).
# 격자 셀 수 권장 상한. 넘으면 **경고만** 남기고 그대로 계산한다 — 격자 크기 자동 조절은
# 하지 않는다(2026-07-31 사용자 지시). 느리면 위 DRAINAGE_GRID_SIZE_M을 직접 올린다.
DRAINAGE_MAX_GRID_CELLS = int(os.getenv("DRAINAGE_MAX_GRID_CELLS", "16000000"))
# 도로 폭(m). 이 폭으로 노선을 격자에 구워 D8 흐름이 도로를 대각선으로 건너뛰지 못하게 한다.
DRAINAGE_ROAD_WIDTH_M = float(os.getenv("DRAINAGE_ROAD_WIDTH_M", "4.0"))
@@ -258,6 +260,9 @@ DRAINAGE_CONTOUR_MARGIN_M = float(os.getenv("DRAINAGE_CONTOUR_MARGIN_M", "10.0")
DRAINAGE_CONTOUR_MIN_LENGTH_M = float(os.getenv("DRAINAGE_CONTOUR_MIN_LENGTH_M", "20.0"))
# 등고선 정점 재샘플 간격(m). 조밀할수록 TIN이 정확하나 Delaunay 비용이 커진다.
DRAINAGE_CONTOUR_RESAMPLE_M = float(os.getenv("DRAINAGE_CONTOUR_RESAMPLE_M", "5.0"))
# TIN 삼각망을 만들 때 격자 범위 밖으로 남길 여유(m). 도엽 전체 등고선을 다 물면 삼각망
# 비용만 커지고 결과는 같다. 여유가 0이면 격자 가장자리가 TIN 밖으로 나가 NaN이 된다.
DRAINAGE_CONTOUR_CLIP_MARGIN_M = float(os.getenv("DRAINAGE_CONTOUR_CLIP_MARGIN_M", "100.0"))
# 평탄면 해소용 미세 경사(m/셀). 채움 후 흐름 방향이 없는 셀에 출구 쪽 경사를 만들어 준다.
DRAINAGE_FLAT_EPSILON_M = float(os.getenv("DRAINAGE_FLAT_EPSILON_M", "0.001"))
# 관 매설 최대 간격(m). 이 간격을 넘으면 흐름 강도가 가장 큰 지점에 관을 보충한다.
@@ -273,6 +278,8 @@ DRAINAGE_MIN_BASIN_AREA_M2 = float(os.getenv("DRAINAGE_MIN_BASIN_AREA_M2", "100.
# 격자 해석 결과 캐시 파일명. 프로젝트 저장소의 B05_wf2_Route/drainage/ 아래에 놓인다.
DRAINAGE_CACHE_DIRNAME = "drainage"
DRAINAGE_CACHE_FILENAME = "watershed_grid.npz"
# 1차 영역 검증 산출물. 버튼을 누를 때마다 덮어써서 사람이 QGIS 등으로 직접 열어볼 수 있게 한다.
DRAINAGE_REGION_FILENAME = "primary_region.geojson"
# ─────────────────────────────────────────────────────────────────────────