658 lines
29 KiB
Python
658 lines
29 KiB
Python
"""배수유역 추적 유틸 — 등고선 공간 인덱스·세류 상류망·분수령(능선) 추적.
|
|
|
|
등고선 기하 직접 분석(2026-07-29 합의)의 하위 도구 모음. DEM 보간 없이:
|
|
- `ContourIndex`: 등고선(선)·표고점(점)을 STRtree에 1회 적재하고 필요한 것만 꺼낸다.
|
|
- `trace_upstream_network`: 세류 교차점에서 도로 산측 상류망만 추적한다(하류 무시).
|
|
- `trace_divider`: 물갈림 지점에서 상향 등고선을 한 겹씩 따라 오르는 유역 분할선(능선 근사).
|
|
|
|
전체 등고선을 순회하는 연산을 두지 않아 분석량이 유역 크기에 비례한다(도엽 매수와 무관).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import bisect
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from shapely.geometry import LineString, Point, Polygon, box, shape
|
|
from shapely.ops import nearest_points, substring, unary_union
|
|
from shapely.strtree import STRtree
|
|
|
|
# 분할선(능선 근사) 추적: 다음 상위 등고선을 찾는 탐색 반경(m)과 최대 단계 수.
|
|
DIVIDER_SEARCH_RADIUS_M = 120.0
|
|
DIVIDER_MAX_STEPS = 60
|
|
# 세류 없는 소규모 유역: 도로 상측 첫 능선까지만 오르도록 좁힌 한계(영역 선정 주의).
|
|
LOCAL_SEARCH_RADIUS_M = 80.0
|
|
LOCAL_MAX_STEPS = 12
|
|
# 세류 연결 판정 이격(m)과 상류망 총연장 상한(m).
|
|
STREAM_JOIN_TOL_M = 15.0
|
|
MAX_UPSTREAM_TOTAL_M = 5000.0
|
|
# 계곡 유역 근사 격자: 셀 크기(m)와 상류망에서의 최대 이격(m).
|
|
VALLEY_CELL_M = 12.0
|
|
VALLEY_CAP_M = 350.0
|
|
# 능선 스냅: 경계 정점에서 등고선 탐색 반경(m)과 등고선 위 능선 꼭짓점 탐색 폭(m).
|
|
RIDGE_SNAP_M = 40.0
|
|
RIDGE_WALK_M = 80.0
|
|
# 능선 행진(개선 2안): 다음 상위 등고선 탐색 반경(m)·등고선 위 꼭짓점 탐색 폭(m)·최대 단계.
|
|
# 탐색 폭을 좁게 유지해야 체인이 자기 능선을 국소 추종한다(넓으면 이웃 능선으로 가로 이탈).
|
|
MARCH_RADIUS_M = 120.0
|
|
MARCH_WALK_M = 40.0
|
|
MARCH_MAX_STEPS = 150
|
|
# 첫 스텝(시드)만 넓게 탐색 — 물갈림점이 능선 위가 아닐 수 있어 국소 분수령을 먼저 찾는다.
|
|
MARCH_SEED_WALK_M = 250.0
|
|
# 이탈 제약 완화 비율 — 등거리선(dn=do) 부근에서 체인이 멈추지 않게 소폭 허용.
|
|
MARCH_OTHER_RATIO = 0.7
|
|
|
|
|
|
@dataclass
|
|
class DividerStep:
|
|
"""분할선의 한 단계 — 어느 등고선(geom_index)의 어느 지점을 밟았는지."""
|
|
|
|
point: Point
|
|
z: float
|
|
geom_index: int
|
|
|
|
|
|
class ContourIndex:
|
|
"""표고 속성이 있는 등고선·표고점 피처의 STRtree 래퍼."""
|
|
|
|
def __init__(self, features: list[dict[str, Any]], elevation_keys: tuple[str, ...]) -> None:
|
|
self.geoms: list[Any] = []
|
|
self.zs: list[float] = []
|
|
for feature in features:
|
|
elevation = _feature_elevation(feature, elevation_keys)
|
|
if elevation is None:
|
|
continue
|
|
geometry = feature.get("geometry") or {}
|
|
try:
|
|
geom = shape(geometry)
|
|
except Exception: # noqa: BLE001 - 손상된 피처는 건너뛴다
|
|
continue
|
|
if geom.is_empty:
|
|
continue
|
|
parts = list(geom.geoms) if geom.geom_type.startswith("Multi") else [geom]
|
|
for part in parts:
|
|
self.geoms.append(part)
|
|
self.zs.append(elevation)
|
|
self.tree = STRtree(self.geoms) if self.geoms else None
|
|
|
|
def query(self, geometry: Any) -> list[int]:
|
|
"""geometry 근방(bbox 교차) 피처의 인덱스만 돌려준다."""
|
|
if self.tree is None:
|
|
return []
|
|
return [int(i) for i in self.tree.query(geometry)]
|
|
|
|
def nearest_elevation(self, point: Point, radius_m: float) -> float | None:
|
|
"""point에서 radius 안 가장 가까운 피처의 표고. 없으면 None."""
|
|
best_z: float | None = None
|
|
best_distance = radius_m
|
|
for index in self.query(point.buffer(radius_m)):
|
|
distance = self.geoms[index].distance(point)
|
|
if distance <= best_distance:
|
|
best_distance = distance
|
|
best_z = self.zs[index]
|
|
return best_z
|
|
|
|
def max_elevation_within(self, polygon: Any) -> float | None:
|
|
"""polygon과 실제로 교차하는 피처들의 최고 표고."""
|
|
best: float | None = None
|
|
for index in self.query(polygon):
|
|
if not polygon.intersects(self.geoms[index]):
|
|
continue
|
|
if best is None or self.zs[index] > best:
|
|
best = self.zs[index]
|
|
return best
|
|
|
|
|
|
def _feature_elevation(feature: dict[str, Any], elevation_keys: tuple[str, ...]) -> float | None:
|
|
properties = feature.get("properties") or {}
|
|
for key in elevation_keys:
|
|
value = properties.get(key)
|
|
if value is None:
|
|
continue
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
return None
|
|
|
|
|
|
def side_sign(road_line: LineString, point: Point) -> int:
|
|
"""도로선 기준 point가 어느 쪽인지(+1/-1, 선상이면 0). 국소 접선과의 외적 부호."""
|
|
t = road_line.project(point)
|
|
a = road_line.interpolate(max(0.0, t - 5.0))
|
|
b = road_line.interpolate(min(road_line.length, t + 5.0))
|
|
cross = (b.x - a.x) * (point.y - a.y) - (b.y - a.y) * (point.x - a.x)
|
|
if cross > 0:
|
|
return 1
|
|
if cross < 0:
|
|
return -1
|
|
return 0
|
|
|
|
|
|
def trace_divider(
|
|
start: Point,
|
|
contour_index: ContourIndex,
|
|
road_line: LineString,
|
|
uphill_sign: int,
|
|
radius_m: float = DIVIDER_SEARCH_RADIUS_M,
|
|
max_steps: int = DIVIDER_MAX_STEPS,
|
|
) -> list[DividerStep]:
|
|
"""물갈림 지점에서 상향 등고선을 한 겹씩 밟아 오르는 분할선을 만든다.
|
|
|
|
각 단계에서 반경 안의 "현재보다 높은 등고선 중 가장 낮은 것"의 최근접점으로 이동한다.
|
|
도로 산측(uphill_sign)을 벗어나거나 도로 쪽으로 되돌아가는 이동은 막는다.
|
|
더 높은 등고선이 반경 안에 없으면 능선(분수령)에 닿은 것으로 보고 멈춘다.
|
|
"""
|
|
z = contour_index.nearest_elevation(start, radius_m)
|
|
if z is None:
|
|
return []
|
|
steps = [DividerStep(point=start, z=z, geom_index=-1)]
|
|
current = start
|
|
road_distance = road_line.distance(start)
|
|
for _ in range(max_steps):
|
|
best: tuple[float, float, Point, int] | None = None
|
|
for index in contour_index.query(current.buffer(radius_m)):
|
|
candidate_z = contour_index.zs[index]
|
|
if candidate_z <= z + 0.01:
|
|
continue
|
|
if best is not None and candidate_z > best[0]:
|
|
continue
|
|
point = nearest_points(contour_index.geoms[index], current)[0]
|
|
distance = current.distance(point)
|
|
if distance > radius_m:
|
|
continue
|
|
# 도로 반대편·도로 방향 후퇴 금지 — 분할선은 산측으로만 오른다.
|
|
if side_sign(road_line, point) == -uphill_sign:
|
|
continue
|
|
if road_line.distance(point) + 1.0 < road_distance:
|
|
continue
|
|
if (
|
|
best is None
|
|
or candidate_z < best[0]
|
|
or (candidate_z == best[0] and distance < best[1])
|
|
):
|
|
best = (candidate_z, distance, point, index)
|
|
if best is None:
|
|
break
|
|
z, _, current, geom_index = best
|
|
road_distance = max(road_distance, road_line.distance(current))
|
|
steps.append(DividerStep(point=current, z=z, geom_index=geom_index))
|
|
return steps
|
|
|
|
|
|
def _explode_lines(stream_features: list[dict[str, Any]]) -> list[LineString]:
|
|
lines: list[LineString] = []
|
|
for feature in stream_features:
|
|
geometry = feature.get("geometry") or {}
|
|
try:
|
|
geom = shape(geometry)
|
|
except Exception: # noqa: BLE001
|
|
continue
|
|
if geom.is_empty:
|
|
continue
|
|
parts = list(geom.geoms) if geom.geom_type.startswith("Multi") else [geom]
|
|
lines.extend(part for part in parts if part.geom_type == "LineString")
|
|
return lines
|
|
|
|
|
|
def _oriented_from(line: LineString, origin: Point) -> LineString:
|
|
"""origin에 가까운 끝이 시작점이 되도록 방향을 맞춘다."""
|
|
if Point(line.coords[0]).distance(origin) <= Point(line.coords[-1]).distance(origin):
|
|
return line
|
|
return LineString(list(line.coords)[::-1])
|
|
|
|
|
|
def _clip_uphill(line: LineString, road_line: LineString, origin: Point) -> LineString | None:
|
|
"""도로를 다시 가로지르면 교차 지점에서 잘라 origin 쪽 조각만 남긴다."""
|
|
if not line.crosses(road_line):
|
|
return line
|
|
t = line.project(nearest_points(line.intersection(road_line), origin)[0])
|
|
piece = substring(line, 0.0, t) if line.project(origin) < t else substring(line, t, line.length)
|
|
if piece.geom_type != "LineString" or piece.length < 1.0:
|
|
return None
|
|
return piece
|
|
|
|
|
|
def valley_region_polygon(
|
|
network_union: Any,
|
|
stream_features: list[dict[str, Any]],
|
|
crossing: Point,
|
|
contour_index: Any = None,
|
|
) -> Any | None:
|
|
"""상류망 계곡의 유역 영역 — 경계는 등고선을 참고한 능선(분수령)으로 긋는다.
|
|
|
|
① 등거리 1차 근사: 격자 셀 중심이 인접 계곡 세류보다 우리 상류망에 가깝고 상한
|
|
거리 이내이면 유역 소속. 지류 사이 사면(지능선) 포함, 인접 계곡 자동 제외.
|
|
② 등고선 스냅(2026-07-29 사용자 지시): 경계는 세류들 사이 중간 어딘가가 아니라
|
|
**등고선을 참고해** 그어야 한다 — 각 경계 정점을 근처 등고선 위에서 두 세류
|
|
모두로부터 가장 먼 지점(능선 꼭짓점)으로 이동. 단 우리 세류를 침범하거나
|
|
인접 세류 너머로 나가지 않는다.
|
|
반환: 폴리곤(간략화됨) 또는 None.
|
|
"""
|
|
lines = _explode_lines(stream_features)
|
|
others = [line for line in lines if line.distance(network_union) > STREAM_JOIN_TOL_M]
|
|
other_tree = STRtree(others) if others else None
|
|
min_x, min_y, max_x, max_y = network_union.buffer(VALLEY_CAP_M).bounds
|
|
cells = []
|
|
y = min_y
|
|
while y < max_y:
|
|
x = min_x
|
|
while x < max_x:
|
|
center = Point(x + VALLEY_CELL_M / 2.0, y + VALLEY_CELL_M / 2.0)
|
|
distance = network_union.distance(center)
|
|
if distance <= VALLEY_CAP_M:
|
|
if other_tree is not None:
|
|
nearest = others[int(other_tree.nearest(center))]
|
|
if nearest.distance(center) < distance:
|
|
x += VALLEY_CELL_M
|
|
continue
|
|
cells.append(box(x, y, x + VALLEY_CELL_M, y + VALLEY_CELL_M))
|
|
x += VALLEY_CELL_M
|
|
y += VALLEY_CELL_M
|
|
if not cells:
|
|
return None
|
|
region = unary_union(cells).buffer(0)
|
|
if region.geom_type == "MultiPolygon":
|
|
touching = [
|
|
part
|
|
for part in region.geoms
|
|
if part.intersects(network_union) or part.distance(crossing) < VALLEY_CELL_M * 2
|
|
]
|
|
region = unary_union(touching) if touching else max(region.geoms, key=lambda p: p.area)
|
|
if region.geom_type == "MultiPolygon":
|
|
region = max(region.geoms, key=lambda p: p.area)
|
|
region = region.simplify(VALLEY_CELL_M, preserve_topology=True)
|
|
if region.is_empty or region.geom_type != "Polygon":
|
|
return None
|
|
if contour_index is not None:
|
|
region = _contour_chain_boundary(region, contour_index, network_union, others, other_tree)
|
|
return region
|
|
|
|
|
|
def _collect_intersection_points(geometry: Any) -> list[Point]:
|
|
"""교차 결과에서 대표 점들을 뽑는다."""
|
|
if geometry.is_empty:
|
|
return []
|
|
if geometry.geom_type == "Point":
|
|
return [geometry]
|
|
if geometry.geom_type in {"MultiPoint", "GeometryCollection"}:
|
|
points: list[Point] = []
|
|
for part in geometry.geoms:
|
|
points.extend(_collect_intersection_points(part))
|
|
return points
|
|
if geometry.geom_type in {"LineString", "MultiLineString"}:
|
|
return [geometry.interpolate(0.5, normalized=True)]
|
|
return []
|
|
|
|
|
|
def _contour_chain_boundary(
|
|
region: Any,
|
|
contour_index: Any,
|
|
network_union: Any,
|
|
others: list[LineString],
|
|
other_tree: STRtree | None,
|
|
) -> Any:
|
|
"""유역 경계를 **등고선마다 능선 포인트 1개씩 찍어 연결**한 체인으로 재구성한다.
|
|
|
|
(2026-07-29 사용자 지시: 정점 스냅은 점이 듬성듬성해 등고선을 건너뛴다.)
|
|
등거리 1차 경계 링은 순서 뼈대로만 쓴다: 링을 가로지르는 모든 등고선 교차점마다
|
|
그 등고선 위에서 두 세류(우리 상류망·인접 세류) 모두로부터 가장 먼 지점(능선
|
|
꼭짓점)을 정제해 포인트를 얻고, 링 위 위치 순으로 연결한다. 등고선이 없는 구간은
|
|
원래 링 정점으로 메운다. 제약: 우리 세류 침범·인접 세류 이탈 금지.
|
|
|
|
2차 보정(2026-07-29 사용자 채택, "2번 방식"): 능선은 등고선의 **직교 궤적**이므로,
|
|
각 포인트를 등고선 위에서 미세 이동해 경계선이 그 등고선과 수직으로 교차하도록
|
|
반복 조정한다(TOPOG/TAPES-C 계열 개념). 능선 점수(세류 최소거리)가 꼭짓점 대비
|
|
크게 떨어지는 이동은 막는다.
|
|
"""
|
|
ring = LineString(region.exterior.coords)
|
|
|
|
def _score(point: Point) -> tuple[float, float]:
|
|
to_network = network_union.distance(point)
|
|
to_other = (
|
|
others[int(other_tree.nearest(point))].distance(point)
|
|
if other_tree is not None
|
|
else float("inf")
|
|
)
|
|
return to_network, to_other
|
|
|
|
# ① 링을 가로지르는 등고선 교차점마다 능선 꼭짓점 1개.
|
|
# entry = [링 위치 s, Point, geom_index(-1=링 정점), 등고선 파라미터 t, 꼭짓점 점수]
|
|
entries: list[list[Any]] = []
|
|
for index in contour_index.query(ring.buffer(1.0)):
|
|
geom = contour_index.geoms[index]
|
|
try:
|
|
crossings = _collect_intersection_points(geom.intersection(ring))
|
|
except Exception: # noqa: BLE001
|
|
continue
|
|
for crossing in crossings:
|
|
s = ring.project(crossing)
|
|
t0 = geom.project(crossing)
|
|
best = None
|
|
best_t = t0
|
|
best_value = -1.0
|
|
steps = int(RIDGE_WALK_M / 10.0)
|
|
for offset in [0.0] + [
|
|
sign * k * 10.0 for k in range(1, steps + 1) for sign in (1, -1)
|
|
]:
|
|
t = min(max(t0 + offset, 0.0), geom.length)
|
|
candidate = geom.interpolate(t)
|
|
if candidate.distance(crossing) > RIDGE_WALK_M + RIDGE_SNAP_M:
|
|
continue
|
|
to_network, to_other = _score(candidate)
|
|
if to_network < STREAM_JOIN_TOL_M:
|
|
continue # 우리 세류 침범 금지
|
|
if to_other < to_network:
|
|
continue # 인접 세류 쪽으로 이탈 금지
|
|
if min(to_network, to_other) > best_value:
|
|
best_value = min(to_network, to_other)
|
|
best = candidate
|
|
best_t = t
|
|
if best is not None:
|
|
entries.append([s, best, index, best_t, best_value])
|
|
if len(entries) < 4:
|
|
return region
|
|
entries.sort(key=lambda entry: entry[0])
|
|
# ② 등고선 공백 구간(교차점 사이가 먼 곳)은 원래 링 정점으로 메운다.
|
|
positions = [entry[0] for entry in entries]
|
|
for x, y in list(region.exterior.coords)[:-1]:
|
|
s = ring.project(Point(x, y))
|
|
slot = bisect.bisect_left(positions, s)
|
|
before = positions[slot - 1] if slot > 0 else positions[-1] - ring.length
|
|
after = positions[slot] if slot < len(positions) else positions[0] + ring.length
|
|
if min(s - before, after - s) > VALLEY_CELL_M * 2.5:
|
|
entries.append([s, Point(x, y), -1, 0.0, 0.0])
|
|
entries.sort(key=lambda entry: entry[0])
|
|
# ③ 직교 보정: 경계 진행방향과 등고선 접선이 수직이 되도록 포인트를 미세 이동.
|
|
entries = _orthogonalize_chain(entries, contour_index, _score)
|
|
polygon = Polygon([(entry[1].x, entry[1].y) for entry in entries]).buffer(0)
|
|
if polygon.geom_type == "MultiPolygon":
|
|
polygon = max(polygon.geoms, key=lambda part: part.area)
|
|
if polygon.is_empty or polygon.geom_type != "Polygon":
|
|
return region
|
|
return polygon
|
|
|
|
|
|
def _orthogonalize_chain(
|
|
entries: list[list[Any]],
|
|
contour_index: Any,
|
|
score: Any,
|
|
) -> list[list[Any]]:
|
|
"""체인 포인트를 등고선 위에서 이동해 경계가 등고선과 직교하게 만든다.
|
|
|
|
각 포인트에서 |등고선 접선 · 체인 진행방향| (수직이면 0)을 최소화한다. 이동 허용
|
|
조건: 세류 침범·이탈 금지 + 능선 점수(두 세류 최소거리)가 꼭짓점 값의 70% 이상.
|
|
2회 반복으로 이웃 이동의 영향을 수렴시킨다.
|
|
"""
|
|
count = len(entries)
|
|
for _ in range(2):
|
|
for i, entry in enumerate(entries):
|
|
geom_index = entry[2]
|
|
if geom_index < 0:
|
|
continue
|
|
geom = contour_index.geoms[geom_index]
|
|
previous = entries[i - 1][1]
|
|
following = entries[(i + 1) % count][1]
|
|
dx, dy = following.x - previous.x, following.y - previous.y
|
|
norm = (dx * dx + dy * dy) ** 0.5
|
|
if norm < 1.0:
|
|
continue
|
|
dx, dy = dx / norm, dy / norm
|
|
floor = 0.7 * entry[4]
|
|
best_t = entry[3]
|
|
best_point = entry[1]
|
|
best_dot = None
|
|
for offset in range(-int(RIDGE_SNAP_M), int(RIDGE_SNAP_M) + 1, 5):
|
|
t = min(max(entry[3] + float(offset), 0.0), geom.length)
|
|
candidate = geom.interpolate(t)
|
|
ahead = geom.interpolate(min(t + 4.0, geom.length))
|
|
behind = geom.interpolate(max(t - 4.0, 0.0))
|
|
tx, ty = ahead.x - behind.x, ahead.y - behind.y
|
|
tangent_norm = (tx * tx + ty * ty) ** 0.5
|
|
if tangent_norm < 0.5:
|
|
continue
|
|
to_network, to_other = score(candidate)
|
|
if to_network < STREAM_JOIN_TOL_M or to_other < to_network:
|
|
continue
|
|
if min(to_network, to_other) < floor:
|
|
continue
|
|
dot = abs((tx * dx + ty * dy) / tangent_norm)
|
|
if best_dot is None or dot < best_dot:
|
|
best_dot = dot
|
|
best_t = t
|
|
best_point = candidate
|
|
entry[1] = best_point
|
|
entry[3] = best_t
|
|
return entries
|
|
|
|
|
|
def trace_ridge_march(
|
|
start: Point,
|
|
contour_index: Any,
|
|
network_union: Any,
|
|
others: list[LineString],
|
|
other_tree: STRtree | None,
|
|
road_line: LineString,
|
|
uphill_sign: int,
|
|
) -> list[DividerStep]:
|
|
"""능선 행진(개선 2안) — 상위 등고선마다 능선 꼭짓점을 한 칸씩 밟아 오른다.
|
|
|
|
수작업 유역도 작도법의 자동화: 물갈림점에서 출발해 매 단계 "반경 안 현재보다 높은
|
|
등고선 중 가장 낮은 것" 위에서 **|우리 상류망까지 거리 − 인접 세류까지 거리|가
|
|
최소인 지점**(분수령 = 두 세류망 등거리점)으로 이동한다. 두 세류에서 가장 먼 점을
|
|
고르면 우리 지류들 사이 내부 지능선으로 새므로, 등거리 조건이 바깥 분수령을 강제
|
|
한다. 꼭짓점 연결선은 등고선과 자연히 직교한다.
|
|
제약: 도로 산측 유지, 우리 세류 침범(15m)·인접 세류 과이탈 금지. 더 높은 등고선이
|
|
없으면(능선 정상) 자연 종료. 반환은 DividerStep 목록 — 기존 폐합 로직과 호환.
|
|
"""
|
|
|
|
def _score(point: Point) -> tuple[float, float]:
|
|
to_network = network_union.distance(point)
|
|
to_other = (
|
|
others[int(other_tree.nearest(point))].distance(point)
|
|
if other_tree is not None
|
|
else float("inf")
|
|
)
|
|
return to_network, to_other
|
|
|
|
z = contour_index.nearest_elevation(start, MARCH_RADIUS_M)
|
|
if z is None:
|
|
return []
|
|
steps_out = [DividerStep(point=start, z=z, geom_index=-1)]
|
|
current = start
|
|
for step_no in range(MARCH_MAX_STEPS):
|
|
walk_m = MARCH_SEED_WALK_M if step_no == 0 else MARCH_WALK_M
|
|
reach_m = max(MARCH_RADIUS_M, walk_m)
|
|
best: tuple[float, float, Point, int] | None = None # (레벨, |dn-do|, 지점, geom idx)
|
|
for index in contour_index.query(current.buffer(reach_m)):
|
|
level = contour_index.zs[index]
|
|
if level <= z + 0.01:
|
|
continue
|
|
if best is not None and level > best[0]:
|
|
continue
|
|
geom = contour_index.geoms[index]
|
|
if geom.distance(current) > reach_m:
|
|
continue
|
|
t0 = geom.project(current)
|
|
walk = int(walk_m / 10.0)
|
|
for offset in [0.0] + [sign * k * 10.0 for k in range(1, walk + 1) for sign in (1, -1)]:
|
|
t = min(max(t0 + offset, 0.0), geom.length)
|
|
candidate = geom.interpolate(t)
|
|
if candidate.distance(current) > reach_m:
|
|
continue
|
|
# 도로 하류측 이탈 금지 — 단, 노선 끝 너머(투영이 끝점에 걸림)는 좌우
|
|
# 부호가 무의미하므로 세류 제약에만 맡긴다(끝을 감아 도는 분수령 허용).
|
|
projection = road_line.project(candidate)
|
|
if (
|
|
5.0 < projection < road_line.length - 5.0
|
|
and side_sign(road_line, candidate) == -uphill_sign
|
|
):
|
|
continue
|
|
to_network, to_other = _score(candidate)
|
|
if to_network < STREAM_JOIN_TOL_M:
|
|
continue # 우리 세류 침범 금지
|
|
if to_other < to_network * MARCH_OTHER_RATIO:
|
|
continue # 인접 세류 쪽 과이탈 금지(등거리선 부근 소폭 허용)
|
|
balance = abs(to_network - to_other)
|
|
if best is None or level < best[0] or (level == best[0] and balance < best[1]):
|
|
best = (level, balance, candidate, index)
|
|
if best is None:
|
|
break
|
|
z = best[0]
|
|
current = best[2]
|
|
steps_out.append(DividerStep(point=current, z=z, geom_index=best[3]))
|
|
return steps_out
|
|
|
|
|
|
def rim_walk(
|
|
start: Point,
|
|
target: Point,
|
|
contour_index: Any,
|
|
network_union: Any,
|
|
others: list[LineString],
|
|
other_tree: STRtree | None,
|
|
road_line: LineString,
|
|
uphill_sign: int,
|
|
) -> list[Point] | None:
|
|
"""능선마루를 따라 두 행진 정상을 잇는다(개선 2안 상측 폐합).
|
|
|
|
좌·우 능선 정상 높이가 달라 단일 등고선 아크로 못 닫는 경우, 매 단계 target에
|
|
가까워지는 등고선 위 지점 중 |우리 세류 거리 − 인접 세류 거리|가 최소인 곳
|
|
(분수령)으로 이동한다. 레벨 제한 없음(마루는 오르내린다). 막히면 None.
|
|
"""
|
|
|
|
def _score(point: Point) -> tuple[float, float]:
|
|
to_network = network_union.distance(point)
|
|
to_other = (
|
|
others[int(other_tree.nearest(point))].distance(point)
|
|
if other_tree is not None
|
|
else float("inf")
|
|
)
|
|
return to_network, to_other
|
|
|
|
points: list[Point] = []
|
|
current = start
|
|
remaining = current.distance(target)
|
|
for _ in range(MARCH_MAX_STEPS):
|
|
if remaining <= MARCH_RADIUS_M:
|
|
return points
|
|
best: tuple[float, Point] | None = None # (|dn-do|, 지점)
|
|
for index in contour_index.query(current.buffer(MARCH_RADIUS_M)):
|
|
geom = contour_index.geoms[index]
|
|
if geom.distance(current) > MARCH_RADIUS_M:
|
|
continue
|
|
t0 = geom.project(current)
|
|
walk = int(MARCH_WALK_M / 10.0)
|
|
for offset in [0.0] + [sign * k * 10.0 for k in range(1, walk + 1) for sign in (1, -1)]:
|
|
t = min(max(t0 + offset, 0.0), geom.length)
|
|
candidate = geom.interpolate(t)
|
|
if candidate.distance(current) > MARCH_RADIUS_M:
|
|
continue
|
|
if candidate.distance(target) > remaining - 5.0:
|
|
continue # target에 실질적으로 가까워지는 이동만 허용
|
|
projection = road_line.project(candidate)
|
|
if (
|
|
5.0 < projection < road_line.length - 5.0
|
|
and side_sign(road_line, candidate) == -uphill_sign
|
|
):
|
|
continue
|
|
to_network, to_other = _score(candidate)
|
|
if to_network < STREAM_JOIN_TOL_M:
|
|
continue
|
|
if to_other < to_network * MARCH_OTHER_RATIO:
|
|
continue
|
|
balance = abs(to_network - to_other)
|
|
if best is None or balance < best[0]:
|
|
best = (balance, candidate)
|
|
if best is None:
|
|
return None
|
|
current = best[1]
|
|
remaining = current.distance(target)
|
|
points.append(current)
|
|
return None
|
|
|
|
|
|
def trace_upstream_network(
|
|
crossing: Point,
|
|
stream_features: list[dict[str, Any]],
|
|
road_line: LineString,
|
|
uphill_sign: int,
|
|
) -> tuple[list[LineString], float]:
|
|
"""세류 교차점에서 도로 산측으로 뻗는 상류망을 추적한다.
|
|
|
|
① 교차한 세류를 교차점에서 잘라 산측 조각을 뿌리로 삼는다.
|
|
② 끝점이 기존 망에 근접(STREAM_JOIN_TOL_M)한 세류를 반복 편입한다(분기 포함).
|
|
도로를 다시 가로지르는 조각은 절단하고, 총연장 상한을 두어 폭주를 막는다.
|
|
반환: (상류망 폴리라인 목록, 최장 유하 경로 길이 m).
|
|
"""
|
|
lines = _explode_lines(stream_features)
|
|
network: list[tuple[LineString, float]] = [] # (폴리라인, 뿌리에서 시작점까지 누적거리)
|
|
used: set[int] = set()
|
|
total = 0.0
|
|
|
|
# ① 뿌리: 교차점을 지나는 세류의 산측 조각. 도엽 세류는 교차점 부근에서 별도
|
|
# 피처로 조각나 있는 경우가 많아, 근접(STREAM_JOIN_TOL_M) 조각도 뿌리로 받는다.
|
|
for index, line in enumerate(lines):
|
|
distance = line.distance(crossing)
|
|
if distance > STREAM_JOIN_TOL_M:
|
|
continue
|
|
used.add(index)
|
|
if distance <= 1.0:
|
|
t = line.project(crossing)
|
|
pieces = [substring(line, 0.0, t), substring(line, t, line.length)]
|
|
else:
|
|
pieces = [line]
|
|
for piece in pieces:
|
|
if piece.geom_type != "LineString" or piece.length < 1.0:
|
|
continue
|
|
oriented = _oriented_from(piece, crossing)
|
|
probe = oriented.interpolate(min(10.0, oriented.length))
|
|
if side_sign(road_line, probe) != uphill_sign:
|
|
continue
|
|
clipped = _clip_uphill(oriented, road_line, crossing)
|
|
if clipped is None:
|
|
continue
|
|
network.append((clipped, 0.0))
|
|
total += clipped.length
|
|
if not network:
|
|
return [], 0.0
|
|
|
|
# ② 편입 반복: 끝점이 망에 닿는 세류를 상류로 붙인다.
|
|
grew = True
|
|
while grew and total < MAX_UPSTREAM_TOTAL_M:
|
|
grew = False
|
|
for index, line in enumerate(lines):
|
|
if index in used:
|
|
continue
|
|
attach: tuple[float, Point, LineString, float] | None = None
|
|
for endpoint in (Point(line.coords[0]), Point(line.coords[-1])):
|
|
for parent, parent_cum in network:
|
|
distance = parent.distance(endpoint)
|
|
if distance > STREAM_JOIN_TOL_M:
|
|
continue
|
|
cum = parent_cum + parent.project(endpoint)
|
|
if attach is None or distance < attach[0]:
|
|
attach = (distance, endpoint, parent, cum)
|
|
if attach is None:
|
|
continue
|
|
used.add(index)
|
|
_, endpoint, _, cum = attach
|
|
# 좌우(산측) 판정은 도로 근처에서만 신뢰 — 노선에서 먼 상류는 투영 기준이
|
|
# 뒤틀려 부호가 뒤집히므로 연결성과 도로 재교차 절단만으로 판단한다.
|
|
near_road = road_line.distance(endpoint) < 2.0 * STREAM_JOIN_TOL_M
|
|
if near_road and side_sign(road_line, line.interpolate(0.5, normalized=True)) == (
|
|
-uphill_sign
|
|
):
|
|
continue
|
|
oriented = _oriented_from(line, endpoint)
|
|
clipped = _clip_uphill(oriented, road_line, endpoint)
|
|
if clipped is None:
|
|
continue
|
|
network.append((clipped, cum))
|
|
total += clipped.length
|
|
grew = True
|
|
|
|
flow_length = max((cum + line.length for line, cum in network), default=0.0)
|
|
return [line for line, _ in network], flow_length
|