auto: 2026-07-30 17:25 (EOMSANGDON-HOME)
This commit is contained in:
@@ -272,6 +272,8 @@ export interface DrainageCandidateResponse {
|
||||
export interface DrainageBasin {
|
||||
index: number;
|
||||
chainage_m: number;
|
||||
/** 관(배관) 매설 지점 좌표 — 계획선 위 마커 렌더용. */
|
||||
outlet_lonlat: [number, number];
|
||||
polygon_lonlat: Array<[number, number]>;
|
||||
area_m2: number;
|
||||
relief_m: number;
|
||||
@@ -283,6 +285,8 @@ export interface DrainageBasinResponse {
|
||||
status: string;
|
||||
project_id: string;
|
||||
route_id: number;
|
||||
/** 산정에 실제 사용된 배관 지점 — 유역이 없는 관도 포함(마커 동기화용). */
|
||||
pipes: DrainageCandidate[];
|
||||
basins: DrainageBasin[];
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from shapely.geometry import LineString, Point, Polygon
|
||||
from shapely.ops import nearest_points, substring, unary_union
|
||||
from shapely.ops import substring, unary_union
|
||||
from shapely.strtree import STRtree
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import (
|
||||
@@ -27,6 +27,11 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import (
|
||||
_interpolate_vertex,
|
||||
estimate_pipe_diameter_mm,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Assemble import (
|
||||
_assemble_polygon,
|
||||
_road_segment_coords,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Subdivide import subdivide_main_polygon
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Trace import (
|
||||
LOCAL_MAX_STEPS,
|
||||
LOCAL_SEARCH_RADIUS_M,
|
||||
@@ -49,8 +54,6 @@ MIN_BASIN_AREA_M2 = 100.0
|
||||
STREAM_COVER_BUFFER_M = 20.0
|
||||
# 도로 양끝에서 하류측으로 뻗는 절단 차단선 길이(m).
|
||||
DOWNHILL_BARRIER_M = 800.0
|
||||
# 폐합 등고선 기하 탐색: 분할선 경로와 등고선의 근접 허용 거리(m).
|
||||
CLOSING_SEARCH_M = 30.0
|
||||
# 유역이 상류망을 덮어야 하는 커버리지 목표(미달 시 버퍼 폴백).
|
||||
CLOSING_COVERAGE_GOAL = 0.95
|
||||
# 산측 판정: 도로 접선의 좌우 수직 오프셋(m)과 표고 조회 반경(m).
|
||||
@@ -117,192 +120,6 @@ def _uphill_sign_at(vertices: list[Any], chainage_m: float, contour_index: Conto
|
||||
return 1 if left_z > right_z else -1
|
||||
|
||||
|
||||
def _road_segment_coords(
|
||||
vertices: list[Any], start_m: float, end_m: float
|
||||
) -> list[tuple[float, float]]:
|
||||
"""분할점 사이 도로 구간의 평면 좌표열(유역 폴리곤의 하측 경계)."""
|
||||
sx, sy, _ = _interpolate_vertex(vertices, start_m)
|
||||
ex, ey, _ = _interpolate_vertex(vertices, end_m)
|
||||
coords = [(sx, sy)]
|
||||
coords.extend(
|
||||
(vertex.x, vertex.y) for vertex in vertices if start_m < vertex.chainage_m < end_m
|
||||
)
|
||||
coords.append((ex, ey))
|
||||
return coords
|
||||
|
||||
|
||||
def _contour_arc(
|
||||
line: Any,
|
||||
p_from: Point,
|
||||
p_to: Point,
|
||||
road_line: LineString,
|
||||
network_union: Any = None,
|
||||
) -> list[tuple[float, float]]:
|
||||
"""등고선에서 두 분할선 접점 사이 아크(상측 경계)를 뽑는다.
|
||||
|
||||
폐합 등고선은 두 방향 아크가 생기므로, 세류 상류망을 가로지르지 않고(계곡을
|
||||
자르지 않고) 도로와도 교차하지 않는(=산측) 쪽을 고른다.
|
||||
"""
|
||||
t1, t2 = sorted((line.project(p_from), line.project(p_to)))
|
||||
arcs = []
|
||||
inner = substring(line, t1, t2)
|
||||
if inner.geom_type == "LineString" and len(inner.coords) >= 2:
|
||||
arcs.append(inner)
|
||||
if getattr(line, "is_closed", False):
|
||||
head = substring(line, t2, line.length)
|
||||
tail = substring(line, 0.0, t1)
|
||||
coords = list(head.coords) + list(tail.coords)[1:]
|
||||
if len(coords) >= 2:
|
||||
arcs.append(LineString(coords))
|
||||
if not arcs:
|
||||
return []
|
||||
scored = []
|
||||
for arc in arcs:
|
||||
crosses_stream = bool(network_union is not None and arc.crosses(network_union))
|
||||
crosses_road = arc.crosses(road_line)
|
||||
midpoint = arc.interpolate(0.5, normalized=True)
|
||||
scored.append((crosses_stream, crosses_road, -road_line.distance(midpoint), arc))
|
||||
scored.sort(key=lambda item: (item[0], item[1], item[2]))
|
||||
arc = scored[0][3]
|
||||
coords = list(arc.coords)
|
||||
if Point(coords[0]).distance(p_from) > Point(coords[-1]).distance(p_from):
|
||||
coords.reverse()
|
||||
return [(float(x), float(y)) for x, y in coords]
|
||||
|
||||
|
||||
def _junction(
|
||||
left: list[DividerStep],
|
||||
right: list[DividerStep],
|
||||
min_z: float | None = None,
|
||||
) -> tuple[int, int, int] | None:
|
||||
"""두 분할선이 같은 등고선 지오메트리를 밟은 폐합 지점(좌 idx, 우 idx, geom idx).
|
||||
|
||||
min_z(세류 상류망 최고 표고)가 있으면 그 **이상인 가장 낮은** 공통 등고선을 고른다 —
|
||||
"세류로 영역을 지정한 뒤 가까운 등고선으로 바로 올려치면 안 된다"(2026-07-29 사용자
|
||||
지시). 계곡 발원부를 넘긴 첫 등고선이 유역 상측 경계가 된다. 없으면 최고 공통 등고선.
|
||||
"""
|
||||
left_keys = {
|
||||
(step.z, step.geom_index): position
|
||||
for position, step in enumerate(left)
|
||||
if step.geom_index >= 0
|
||||
}
|
||||
matches: list[tuple[float, int, int, int]] = []
|
||||
for position, step in enumerate(right):
|
||||
if step.geom_index < 0:
|
||||
continue
|
||||
left_position = left_keys.get((step.z, step.geom_index))
|
||||
if left_position is None:
|
||||
continue
|
||||
matches.append((step.z, left_position, position, step.geom_index))
|
||||
if not matches:
|
||||
return None
|
||||
if min_z is not None:
|
||||
above = [match for match in matches if match[0] >= min_z]
|
||||
if above:
|
||||
best = min(above)
|
||||
return best[1], best[2], best[3]
|
||||
best = max(matches)
|
||||
return best[1], best[2], best[3]
|
||||
|
||||
|
||||
def _closing_contour(
|
||||
left: list[DividerStep],
|
||||
right: list[DividerStep],
|
||||
contour_index: ContourIndex,
|
||||
min_z: float,
|
||||
) -> tuple[int, int, int, Point, Point] | None:
|
||||
"""두 분할선 경로에 모두 근접한 등고선 중 min_z 이상 최저를 찾는다.
|
||||
|
||||
분할선이 같은 스텝에서 같은 지오메트리를 밟지 못해도(도엽 분할 등) 계곡 발원부
|
||||
위를 지나는 폐합 등고선을 기하적으로 찾아낸다.
|
||||
반환: (좌 절단 idx, 우 절단 idx, 등고선 geom idx, 좌 접점, 우 접점).
|
||||
"""
|
||||
if len(left) < 2 or len(right) < 2:
|
||||
return None
|
||||
left_line = LineString([step.point for step in left])
|
||||
right_line = LineString([step.point for step in right])
|
||||
shared = set(contour_index.query(left_line.buffer(CLOSING_SEARCH_M))) & set(
|
||||
contour_index.query(right_line.buffer(CLOSING_SEARCH_M))
|
||||
)
|
||||
best: tuple[float, int] | None = None
|
||||
for index in shared:
|
||||
z = contour_index.zs[index]
|
||||
if z < min_z:
|
||||
continue
|
||||
geom = contour_index.geoms[index]
|
||||
if (
|
||||
geom.distance(left_line) > CLOSING_SEARCH_M
|
||||
or geom.distance(right_line) > CLOSING_SEARCH_M
|
||||
):
|
||||
continue
|
||||
if best is None or z < best[0]:
|
||||
best = (z, index)
|
||||
if best is None:
|
||||
return None
|
||||
geom = contour_index.geoms[best[1]]
|
||||
left_touch = nearest_points(geom, left_line)[0]
|
||||
right_touch = nearest_points(geom, right_line)[0]
|
||||
left_position = min(range(len(left)), key=lambda i: left[i].point.distance(left_touch))
|
||||
right_position = min(range(len(right)), key=lambda i: right[i].point.distance(right_touch))
|
||||
return left_position, right_position, best[1], left_touch, right_touch
|
||||
|
||||
|
||||
def _assemble_polygon(
|
||||
vertices: list[Any],
|
||||
start_m: float,
|
||||
end_m: float,
|
||||
left: list[DividerStep],
|
||||
right: list[DividerStep],
|
||||
contour_index: ContourIndex,
|
||||
road_line: LineString,
|
||||
network_union: Any = None,
|
||||
valley_top_z: float | None = None,
|
||||
) -> Polygon | None:
|
||||
"""도로 구간 + 우측 분할선 + 상측 등고선 아크 + 좌측 분할선으로 폴리곤을 폐합한다.
|
||||
|
||||
세류 유역(valley_top_z 지정)은 발원부 위를 지나는 폐합 등고선을 기하 탐색으로
|
||||
먼저 찾고, 실패 시 같은 스텝 매칭(_junction)으로 폐합한다.
|
||||
"""
|
||||
ring = _road_segment_coords(vertices, start_m, end_m)
|
||||
left_used, right_used, arc = left, right, []
|
||||
closure = (
|
||||
_closing_contour(left, right, contour_index, valley_top_z)
|
||||
if valley_top_z is not None
|
||||
else None
|
||||
)
|
||||
if closure is not None:
|
||||
left_position, right_position, geom_index, left_touch, right_touch = closure
|
||||
left_used = left[: left_position + 1]
|
||||
right_used = right[: right_position + 1]
|
||||
arc = _contour_arc(
|
||||
contour_index.geoms[geom_index], right_touch, left_touch, road_line, network_union
|
||||
)
|
||||
else:
|
||||
junction = _junction(left, right, min_z=valley_top_z)
|
||||
if junction is not None:
|
||||
left_position, right_position, geom_index = junction
|
||||
left_used = left[: left_position + 1]
|
||||
right_used = right[: right_position + 1]
|
||||
arc = _contour_arc(
|
||||
contour_index.geoms[geom_index],
|
||||
right_used[-1].point,
|
||||
left_used[-1].point,
|
||||
road_line,
|
||||
network_union,
|
||||
)
|
||||
ring.extend((step.point.x, step.point.y) for step in right_used[1:])
|
||||
ring.extend(arc)
|
||||
ring.extend((step.point.x, step.point.y) for step in reversed(left_used[1:]))
|
||||
if len(ring) < 4:
|
||||
return None
|
||||
polygon = Polygon(ring).buffer(0)
|
||||
if polygon.geom_type == "MultiPolygon":
|
||||
polygon = max(polygon.geoms, key=lambda part: part.area)
|
||||
if polygon.is_empty or polygon.geom_type != "Polygon":
|
||||
return None
|
||||
return polygon
|
||||
|
||||
|
||||
def _clip_to_uphill(
|
||||
polygon: Polygon,
|
||||
road_line: LineString,
|
||||
@@ -480,6 +297,150 @@ def _refine_road_edge(polygon: Polygon, road_line: LineString) -> Polygon:
|
||||
return refined
|
||||
|
||||
|
||||
def _main_watershed_polygon(
|
||||
vertices: list[Any],
|
||||
divides: list[float],
|
||||
dividers: list[list[DividerStep]],
|
||||
contour_index: ContourIndex,
|
||||
road_line: LineString,
|
||||
uphill_sign: int,
|
||||
network_union: Any,
|
||||
stream_lines: list[Any],
|
||||
stream_features: list[dict[str, Any]],
|
||||
outlet: Point,
|
||||
) -> Polygon | None:
|
||||
"""메인 배수유역 폴리곤 1회 산정 — f72017a 채택 산식 그대로.
|
||||
|
||||
불변 조건(2026-07-30 사용자 지시): 이 함수의 산식은 전체 유역 경계를 결정하므로
|
||||
변경 금지. 세분화는 이 결과를 내부에서만 쪼갠다(`subdivide_main_polygon`).
|
||||
"""
|
||||
valley_top_z = contour_index.max_elevation_within(network_union.buffer(10.0))
|
||||
# 개선 2안: 도로 양끝 물갈림점에서 능선 행진으로 경계를 직접 그린다.
|
||||
polygon = None
|
||||
if valley_top_z is not None:
|
||||
polygon = _assemble_march_polygon(
|
||||
vertices,
|
||||
divides[0],
|
||||
divides[-1],
|
||||
contour_index,
|
||||
road_line,
|
||||
uphill_sign,
|
||||
network_union,
|
||||
stream_lines,
|
||||
valley_top_z,
|
||||
)
|
||||
if polygon is None:
|
||||
# 개선 1안(폴백): 도로변 스트립 + 세류 계곡 영역(등거리+등고선 체인) 합집합.
|
||||
base = _assemble_polygon(
|
||||
vertices,
|
||||
divides[0],
|
||||
divides[-1],
|
||||
dividers[0],
|
||||
dividers[-1],
|
||||
contour_index,
|
||||
road_line,
|
||||
network_union,
|
||||
valley_top_z,
|
||||
)
|
||||
valley = valley_region_polygon(network_union, stream_features, outlet, contour_index)
|
||||
if base is None and valley is None:
|
||||
return None
|
||||
if base is not None and valley is not None:
|
||||
merged = base.union(valley).buffer(0)
|
||||
if merged.geom_type == "MultiPolygon":
|
||||
merged = max(merged.geoms, key=lambda part: part.area)
|
||||
polygon = merged if merged.geom_type == "Polygon" and not merged.is_empty else base
|
||||
else:
|
||||
polygon = base if base is not None else valley
|
||||
coverage = network_union.intersection(polygon).length / max(network_union.length, 1.0)
|
||||
if coverage < CLOSING_COVERAGE_GOAL:
|
||||
# 계곡 영역이 못 덮은 상류망만 버퍼로 보정한다(최후 폴백).
|
||||
covered = polygon.union(network_union.buffer(STREAM_COVER_BUFFER_M)).buffer(0)
|
||||
if covered.geom_type == "MultiPolygon":
|
||||
covered = max(covered.geoms, key=lambda part: part.area)
|
||||
if covered.geom_type == "Polygon" and not covered.is_empty:
|
||||
polygon = covered
|
||||
# 마지막에 도로선으로 절단해 하류측을 제거한다 — 도로가 유역의 한쪽 경계.
|
||||
polygon = _clip_to_uphill(polygon, road_line, uphill_sign, contour_index, network_union)
|
||||
if polygon is None or polygon.area < MIN_BASIN_AREA_M2:
|
||||
return None
|
||||
return polygon
|
||||
|
||||
|
||||
def _local_polygon(
|
||||
vertices: list[Any],
|
||||
divides: list[float],
|
||||
dividers: list[list[DividerStep]],
|
||||
position: int,
|
||||
contour_index: ContourIndex,
|
||||
road_line: LineString,
|
||||
uphill_sign: int,
|
||||
) -> Polygon | None:
|
||||
"""세류 없는 관 구간의 소범위 유역 — 도로 상측 첫 능선까지(기존 경로 유지)."""
|
||||
base = _assemble_polygon(
|
||||
vertices,
|
||||
divides[position],
|
||||
divides[position + 1],
|
||||
dividers[position],
|
||||
dividers[position + 1],
|
||||
contour_index,
|
||||
road_line,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
if base is None:
|
||||
return None
|
||||
return _clip_to_uphill(base, road_line, uphill_sign, contour_index, None)
|
||||
|
||||
|
||||
def _basin_from_polygon(
|
||||
polygon: Polygon,
|
||||
candidate: StructureCandidate,
|
||||
index: int,
|
||||
flow_length: float,
|
||||
contour_index: ContourIndex,
|
||||
road_line: LineString,
|
||||
vertices: list[Any],
|
||||
simplify: bool = True,
|
||||
) -> WatershedBasin:
|
||||
"""확정된 유역 폴리곤에서 산출값(면적·표고차·유하장·관경)을 계산한다.
|
||||
|
||||
세분화 조각(simplify=False)은 단순화하지 않는다 — 조각별 독립 단순화는 공유
|
||||
분할선 경계를 어긋나게 해 겹침·틈을 만든다(배타적 타일링 유지).
|
||||
"""
|
||||
outlet = Point(candidate.x, candidate.y)
|
||||
outlet_z = contour_index.nearest_elevation(outlet, UPHILL_PROBE_RADIUS_M)
|
||||
if outlet_z is None:
|
||||
outlet_z = _interpolate_vertex(vertices, candidate.chainage_m)[2]
|
||||
# 표고차는 등고선만으로 계산한다(표고점 미참조 — 사용자 지시).
|
||||
top_z = contour_index.max_elevation_within(polygon) or outlet_z
|
||||
boundary_line = polygon.simplify(5.0, preserve_topology=True) if simplify else polygon
|
||||
if boundary_line.is_empty or boundary_line.geom_type != "Polygon":
|
||||
boundary_line = polygon
|
||||
# 도로변 경계는 단순화 없이 도로선 해상도를 유지한다.
|
||||
boundary_line = _refine_road_edge(boundary_line, road_line)
|
||||
boundary = [[float(x), float(y)] for x, y in boundary_line.exterior.coords]
|
||||
if flow_length <= 0.0:
|
||||
flow_length = max(
|
||||
(math.dist((candidate.x, candidate.y), point) for point in boundary),
|
||||
default=0.0,
|
||||
)
|
||||
basin = WatershedBasin(
|
||||
index=index,
|
||||
chainage_m=candidate.chainage_m,
|
||||
outlet_x=candidate.x,
|
||||
outlet_y=candidate.y,
|
||||
boundary_xy=boundary,
|
||||
area_m2=float(polygon.area),
|
||||
relief_m=max(0.0, float(top_z) - float(outlet_z)),
|
||||
flow_length_m=float(flow_length),
|
||||
)
|
||||
basin.pipe_diameter_mm = estimate_pipe_diameter_mm(
|
||||
basin.area_m2, basin.relief_m, basin.flow_length_m
|
||||
)
|
||||
return basin
|
||||
|
||||
|
||||
def build_watershed_basins(
|
||||
vertices: list[Any],
|
||||
candidates: list[StructureCandidate],
|
||||
@@ -488,10 +449,11 @@ def build_watershed_basins(
|
||||
elevation_keys: tuple[str, ...],
|
||||
stream_features: list[dict[str, Any]] | None = None,
|
||||
) -> list[WatershedBasin]:
|
||||
"""관 지점 배치를 기준으로 메인 배수유역을 세분화해 산정한다.
|
||||
"""메인 배수유역을 1회 산정하고 관 지점 기준으로 내부 세분화한다.
|
||||
|
||||
세류 교차 관("stream")은 상류망을 추적해 넓은 한계로, 세류 없는 관은 도로 상측
|
||||
첫 능선까지 소범위 한계로 분할선을 올린다(작은 유역, 영역 선정 주의 — 사용자 지시).
|
||||
메인 유역 경계는 관 개수와 무관하게 항상 동일하다(불변 조건 — 2026-07-30 사용자
|
||||
지시). 세부유역 = 메인 폴리곤을 관 사이 분할선으로 쪼갠 조각(배타적, 합집합 =
|
||||
메인). 세류 없는 관이 메인 범위 밖이면 소범위 유역을 별도 생성(기존 동작).
|
||||
번호는 노선 시점에 가까운 순.
|
||||
"""
|
||||
if not candidates or len(vertices) < 2:
|
||||
@@ -541,106 +503,70 @@ def build_watershed_basins(
|
||||
)
|
||||
dividers.append(steps)
|
||||
|
||||
basins: list[WatershedBasin] = []
|
||||
# 관별 상류망은 1회만 추적한다(유하장 계산에도 사용).
|
||||
networks: list[list[Any]] = []
|
||||
flows: list[float] = []
|
||||
for position, candidate in enumerate(ordered):
|
||||
outlet = Point(candidate.x, candidate.y)
|
||||
network: list[Any] = []
|
||||
flow_length = 0.0
|
||||
if is_stream[position] and stream_features:
|
||||
network, flow_length = trace_upstream_network(
|
||||
outlet, stream_features, road_line, signs[position]
|
||||
Point(candidate.x, candidate.y), stream_features, road_line, signs[position]
|
||||
)
|
||||
# 계곡 발원부(상류망 최고 표고) — 유역 상측 폐합 등고선은 이보다 높아야 한다.
|
||||
network_union = unary_union(network) if network else None
|
||||
valley_top_z = (
|
||||
contour_index.max_elevation_within(network_union.buffer(10.0))
|
||||
if network_union is not None
|
||||
else None
|
||||
networks.append(network)
|
||||
flows.append(flow_length)
|
||||
|
||||
main_polygon = None
|
||||
combined = [line for network in networks for line in network]
|
||||
first_stream = next((position for position in range(len(ordered)) if networks[position]), None)
|
||||
if combined and first_stream is not None:
|
||||
main_polygon = _main_watershed_polygon(
|
||||
vertices,
|
||||
divides,
|
||||
dividers,
|
||||
contour_index,
|
||||
road_line,
|
||||
majority,
|
||||
unary_union(combined),
|
||||
stream_lines,
|
||||
stream_features or [],
|
||||
Point(ordered[first_stream].x, ordered[first_stream].y),
|
||||
)
|
||||
# 개선 2안: 도로 양끝 물갈림점에서 능선 행진으로 경계를 직접 그린다.
|
||||
polygon = None
|
||||
if network_union is not None and valley_top_z is not None:
|
||||
polygon = _assemble_march_polygon(
|
||||
vertices,
|
||||
divides[position],
|
||||
divides[position + 1],
|
||||
contour_index,
|
||||
road_line,
|
||||
signs[position],
|
||||
network_union,
|
||||
stream_lines,
|
||||
valley_top_z,
|
||||
)
|
||||
|
||||
pieces: list[Polygon | None] = [None] * len(ordered)
|
||||
if main_polygon is not None:
|
||||
divide_points = [
|
||||
Point(*_interpolate_vertex(vertices, chainage)[:2]) for chainage in divides
|
||||
]
|
||||
pieces = subdivide_main_polygon(main_polygon, divide_points, dividers, road_line)
|
||||
|
||||
basins: list[WatershedBasin] = []
|
||||
for position, candidate in enumerate(ordered):
|
||||
polygon = pieces[position] if position < len(pieces) else None
|
||||
from_subdivision = polygon is not None and len(ordered) > 1
|
||||
if polygon is None:
|
||||
# 개선 1안(폴백): 도로변 스트립 + 세류 계곡 영역(등거리+등고선 체인) 합집합.
|
||||
base = _assemble_polygon(
|
||||
vertices,
|
||||
divides[position],
|
||||
divides[position + 1],
|
||||
dividers[position],
|
||||
dividers[position + 1],
|
||||
contour_index,
|
||||
road_line,
|
||||
network_union,
|
||||
valley_top_z,
|
||||
)
|
||||
valley = (
|
||||
valley_region_polygon(network_union, stream_features or [], outlet, contour_index)
|
||||
if network_union is not None
|
||||
else None
|
||||
)
|
||||
if base is None and valley is None:
|
||||
if networks[position] and main_polygon is not None:
|
||||
logger.warning(
|
||||
"세류 관(%.0fm) 구간에 세부유역 조각이 없습니다 — 건너뜁니다.",
|
||||
candidate.chainage_m,
|
||||
)
|
||||
continue
|
||||
if base is not None and valley is not None:
|
||||
merged = base.union(valley).buffer(0)
|
||||
if merged.geom_type == "MultiPolygon":
|
||||
merged = max(merged.geoms, key=lambda part: part.area)
|
||||
polygon = merged if merged.geom_type == "Polygon" and not merged.is_empty else base
|
||||
else:
|
||||
polygon = base if base is not None else valley
|
||||
if network_union is not None:
|
||||
coverage = network_union.intersection(polygon).length / max(network_union.length, 1.0)
|
||||
if coverage < CLOSING_COVERAGE_GOAL:
|
||||
# 계곡 영역이 못 덮은 상류망만 버퍼로 보정한다(최후 폴백).
|
||||
covered = polygon.union(network_union.buffer(STREAM_COVER_BUFFER_M)).buffer(0)
|
||||
if covered.geom_type == "MultiPolygon":
|
||||
covered = max(covered.geoms, key=lambda part: part.area)
|
||||
if covered.geom_type == "Polygon" and not covered.is_empty:
|
||||
polygon = covered
|
||||
# 마지막에 도로선으로 절단해 하류측을 제거한다 — 도로가 유역의 한쪽 경계.
|
||||
polygon = _clip_to_uphill(polygon, road_line, signs[position], contour_index, network_union)
|
||||
# 메인 유역 밖(또는 상류망 없음) 관은 소범위 유역을 별도 생성한다.
|
||||
polygon = _local_polygon(
|
||||
vertices, divides, dividers, position, contour_index, road_line, signs[position]
|
||||
)
|
||||
if polygon is None or polygon.area < MIN_BASIN_AREA_M2:
|
||||
continue
|
||||
|
||||
outlet_z = contour_index.nearest_elevation(outlet, UPHILL_PROBE_RADIUS_M)
|
||||
if outlet_z is None:
|
||||
outlet_z = _interpolate_vertex(vertices, candidate.chainage_m)[2]
|
||||
# 표고차는 등고선만으로 계산한다(표고점 미참조 — 사용자 지시).
|
||||
top_z = contour_index.max_elevation_within(polygon) or outlet_z
|
||||
boundary_line = polygon.simplify(5.0, preserve_topology=True)
|
||||
if boundary_line.is_empty or boundary_line.geom_type != "Polygon":
|
||||
boundary_line = polygon
|
||||
# 도로변 경계는 단순화 없이 도로선 해상도를 유지한다.
|
||||
boundary_line = _refine_road_edge(boundary_line, road_line)
|
||||
boundary = [[float(x), float(y)] for x, y in boundary_line.exterior.coords]
|
||||
if flow_length <= 0.0:
|
||||
flow_length = max(
|
||||
(math.dist((candidate.x, candidate.y), point) for point in boundary),
|
||||
default=0.0,
|
||||
basins.append(
|
||||
_basin_from_polygon(
|
||||
polygon,
|
||||
candidate,
|
||||
len(basins) + 1,
|
||||
flows[position],
|
||||
contour_index,
|
||||
road_line,
|
||||
vertices,
|
||||
simplify=not from_subdivision,
|
||||
)
|
||||
|
||||
basin = WatershedBasin(
|
||||
index=len(basins) + 1,
|
||||
chainage_m=candidate.chainage_m,
|
||||
outlet_x=candidate.x,
|
||||
outlet_y=candidate.y,
|
||||
boundary_xy=boundary,
|
||||
area_m2=float(polygon.area),
|
||||
relief_m=max(0.0, float(top_z) - float(outlet_z)),
|
||||
flow_length_m=float(flow_length),
|
||||
)
|
||||
basin.pipe_diameter_mm = estimate_pipe_diameter_mm(
|
||||
basin.area_m2, basin.relief_m, basin.flow_length_m
|
||||
)
|
||||
basins.append(basin)
|
||||
return basins
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""배수유역 폴리곤 폐합 조립 — 도로 구간 + 분할선 + 등고선 아크 (700줄 분리, 2026-07-30).
|
||||
|
||||
`B05_wf2_Route_Engine_Drainage_Watershed.py`에서 산식 변경 없이 그대로 옮겨온
|
||||
개선 1안(등거리+체인) 폐합 헬퍼 모음이다. 불변 조건: 산식 수정 금지(메인 유역
|
||||
경계가 바뀐다 — 2026-07-30 사용자 지시).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from shapely.geometry import LineString, Point, Polygon
|
||||
from shapely.ops import nearest_points, substring
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import _interpolate_vertex
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Trace import ContourIndex, DividerStep
|
||||
|
||||
# 폐합 등고선 기하 탐색: 분할선 경로와 등고선의 근접 허용 거리(m).
|
||||
CLOSING_SEARCH_M = 30.0
|
||||
|
||||
|
||||
def _road_segment_coords(
|
||||
vertices: list[Any], start_m: float, end_m: float
|
||||
) -> list[tuple[float, float]]:
|
||||
"""분할점 사이 도로 구간의 평면 좌표열(유역 폴리곤의 하측 경계)."""
|
||||
sx, sy, _ = _interpolate_vertex(vertices, start_m)
|
||||
ex, ey, _ = _interpolate_vertex(vertices, end_m)
|
||||
coords = [(sx, sy)]
|
||||
coords.extend(
|
||||
(vertex.x, vertex.y) for vertex in vertices if start_m < vertex.chainage_m < end_m
|
||||
)
|
||||
coords.append((ex, ey))
|
||||
return coords
|
||||
|
||||
|
||||
def _contour_arc(
|
||||
line: Any,
|
||||
p_from: Point,
|
||||
p_to: Point,
|
||||
road_line: LineString,
|
||||
network_union: Any = None,
|
||||
) -> list[tuple[float, float]]:
|
||||
"""등고선에서 두 분할선 접점 사이 아크(상측 경계)를 뽑는다.
|
||||
|
||||
폐합 등고선은 두 방향 아크가 생기므로, 세류 상류망을 가로지르지 않고(계곡을
|
||||
자르지 않고) 도로와도 교차하지 않는(=산측) 쪽을 고른다.
|
||||
"""
|
||||
t1, t2 = sorted((line.project(p_from), line.project(p_to)))
|
||||
arcs = []
|
||||
inner = substring(line, t1, t2)
|
||||
if inner.geom_type == "LineString" and len(inner.coords) >= 2:
|
||||
arcs.append(inner)
|
||||
if getattr(line, "is_closed", False):
|
||||
head = substring(line, t2, line.length)
|
||||
tail = substring(line, 0.0, t1)
|
||||
coords = list(head.coords) + list(tail.coords)[1:]
|
||||
if len(coords) >= 2:
|
||||
arcs.append(LineString(coords))
|
||||
if not arcs:
|
||||
return []
|
||||
scored = []
|
||||
for arc in arcs:
|
||||
crosses_stream = bool(network_union is not None and arc.crosses(network_union))
|
||||
crosses_road = arc.crosses(road_line)
|
||||
midpoint = arc.interpolate(0.5, normalized=True)
|
||||
scored.append((crosses_stream, crosses_road, -road_line.distance(midpoint), arc))
|
||||
scored.sort(key=lambda item: (item[0], item[1], item[2]))
|
||||
arc = scored[0][3]
|
||||
coords = list(arc.coords)
|
||||
if Point(coords[0]).distance(p_from) > Point(coords[-1]).distance(p_from):
|
||||
coords.reverse()
|
||||
return [(float(x), float(y)) for x, y in coords]
|
||||
|
||||
|
||||
def _junction(
|
||||
left: list[DividerStep],
|
||||
right: list[DividerStep],
|
||||
min_z: float | None = None,
|
||||
) -> tuple[int, int, int] | None:
|
||||
"""두 분할선이 같은 등고선 지오메트리를 밟은 폐합 지점(좌 idx, 우 idx, geom idx).
|
||||
|
||||
min_z(세류 상류망 최고 표고)가 있으면 그 **이상인 가장 낮은** 공통 등고선을 고른다 —
|
||||
"세류로 영역을 지정한 뒤 가까운 등고선으로 바로 올려치면 안 된다"(2026-07-29 사용자
|
||||
지시). 계곡 발원부를 넘긴 첫 등고선이 유역 상측 경계가 된다. 없으면 최고 공통 등고선.
|
||||
"""
|
||||
left_keys = {
|
||||
(step.z, step.geom_index): position
|
||||
for position, step in enumerate(left)
|
||||
if step.geom_index >= 0
|
||||
}
|
||||
matches: list[tuple[float, int, int, int]] = []
|
||||
for position, step in enumerate(right):
|
||||
if step.geom_index < 0:
|
||||
continue
|
||||
left_position = left_keys.get((step.z, step.geom_index))
|
||||
if left_position is None:
|
||||
continue
|
||||
matches.append((step.z, left_position, position, step.geom_index))
|
||||
if not matches:
|
||||
return None
|
||||
if min_z is not None:
|
||||
above = [match for match in matches if match[0] >= min_z]
|
||||
if above:
|
||||
best = min(above)
|
||||
return best[1], best[2], best[3]
|
||||
best = max(matches)
|
||||
return best[1], best[2], best[3]
|
||||
|
||||
|
||||
def _closing_contour(
|
||||
left: list[DividerStep],
|
||||
right: list[DividerStep],
|
||||
contour_index: ContourIndex,
|
||||
min_z: float,
|
||||
) -> tuple[int, int, int, Point, Point] | None:
|
||||
"""두 분할선 경로에 모두 근접한 등고선 중 min_z 이상 최저를 찾는다.
|
||||
|
||||
분할선이 같은 스텝에서 같은 지오메트리를 밟지 못해도(도엽 분할 등) 계곡 발원부
|
||||
위를 지나는 폐합 등고선을 기하적으로 찾아낸다.
|
||||
반환: (좌 절단 idx, 우 절단 idx, 등고선 geom idx, 좌 접점, 우 접점).
|
||||
"""
|
||||
if len(left) < 2 or len(right) < 2:
|
||||
return None
|
||||
left_line = LineString([step.point for step in left])
|
||||
right_line = LineString([step.point for step in right])
|
||||
shared = set(contour_index.query(left_line.buffer(CLOSING_SEARCH_M))) & set(
|
||||
contour_index.query(right_line.buffer(CLOSING_SEARCH_M))
|
||||
)
|
||||
best: tuple[float, int] | None = None
|
||||
for index in shared:
|
||||
z = contour_index.zs[index]
|
||||
if z < min_z:
|
||||
continue
|
||||
geom = contour_index.geoms[index]
|
||||
if (
|
||||
geom.distance(left_line) > CLOSING_SEARCH_M
|
||||
or geom.distance(right_line) > CLOSING_SEARCH_M
|
||||
):
|
||||
continue
|
||||
if best is None or z < best[0]:
|
||||
best = (z, index)
|
||||
if best is None:
|
||||
return None
|
||||
geom = contour_index.geoms[best[1]]
|
||||
left_touch = nearest_points(geom, left_line)[0]
|
||||
right_touch = nearest_points(geom, right_line)[0]
|
||||
left_position = min(range(len(left)), key=lambda i: left[i].point.distance(left_touch))
|
||||
right_position = min(range(len(right)), key=lambda i: right[i].point.distance(right_touch))
|
||||
return left_position, right_position, best[1], left_touch, right_touch
|
||||
|
||||
|
||||
def _assemble_polygon(
|
||||
vertices: list[Any],
|
||||
start_m: float,
|
||||
end_m: float,
|
||||
left: list[DividerStep],
|
||||
right: list[DividerStep],
|
||||
contour_index: ContourIndex,
|
||||
road_line: LineString,
|
||||
network_union: Any = None,
|
||||
valley_top_z: float | None = None,
|
||||
) -> Polygon | None:
|
||||
"""도로 구간 + 우측 분할선 + 상측 등고선 아크 + 좌측 분할선으로 폴리곤을 폐합한다.
|
||||
|
||||
세류 유역(valley_top_z 지정)은 발원부 위를 지나는 폐합 등고선을 기하 탐색으로
|
||||
먼저 찾고, 실패 시 같은 스텝 매칭(_junction)으로 폐합한다.
|
||||
"""
|
||||
ring = _road_segment_coords(vertices, start_m, end_m)
|
||||
left_used, right_used, arc = left, right, []
|
||||
closure = (
|
||||
_closing_contour(left, right, contour_index, valley_top_z)
|
||||
if valley_top_z is not None
|
||||
else None
|
||||
)
|
||||
if closure is not None:
|
||||
left_position, right_position, geom_index, left_touch, right_touch = closure
|
||||
left_used = left[: left_position + 1]
|
||||
right_used = right[: right_position + 1]
|
||||
arc = _contour_arc(
|
||||
contour_index.geoms[geom_index], right_touch, left_touch, road_line, network_union
|
||||
)
|
||||
else:
|
||||
junction = _junction(left, right, min_z=valley_top_z)
|
||||
if junction is not None:
|
||||
left_position, right_position, geom_index = junction
|
||||
left_used = left[: left_position + 1]
|
||||
right_used = right[: right_position + 1]
|
||||
arc = _contour_arc(
|
||||
contour_index.geoms[geom_index],
|
||||
right_used[-1].point,
|
||||
left_used[-1].point,
|
||||
road_line,
|
||||
network_union,
|
||||
)
|
||||
ring.extend((step.point.x, step.point.y) for step in right_used[1:])
|
||||
ring.extend(arc)
|
||||
ring.extend((step.point.x, step.point.y) for step in reversed(left_used[1:]))
|
||||
if len(ring) < 4:
|
||||
return None
|
||||
polygon = Polygon(ring).buffer(0)
|
||||
if polygon.geom_type == "MultiPolygon":
|
||||
polygon = max(polygon.geoms, key=lambda part: part.area)
|
||||
if polygon.is_empty or polygon.geom_type != "Polygon":
|
||||
return None
|
||||
return polygon
|
||||
@@ -0,0 +1,171 @@
|
||||
"""메인 배수유역 내부 세분화 — 분할선으로 폴리곤을 쪼갠다 (2026-07-30).
|
||||
|
||||
불변 조건(사용자 지시): 전체(메인) 배수유역 경계는 절대 변경하지 않는다.
|
||||
세분화는 확정된 메인 유역 폴리곤을 관 사이 분할선(물갈림 고개에서 오르는
|
||||
능선 근사선)으로 **내부에서만** 쪼개는 방식이다 — 외곽 재추적 금지.
|
||||
따라서 세부유역은 서로 배타적이고 합집합은 항상 메인 유역과 동일하다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
|
||||
from shapely.geometry import LineString, Point, Polygon
|
||||
from shapely.ops import split as shapely_split
|
||||
from shapely.ops import substring, unary_union
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Trace import DividerStep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 분할 절단선 연장: 도로 하류측(m)과 능선 너머(폴리곤 대각선 배수).
|
||||
CUT_ROAD_TAIL_M = 40.0
|
||||
CUT_RIDGE_EXTEND_RATIO = 1.5
|
||||
|
||||
|
||||
def _cut_line(
|
||||
road_point: Point,
|
||||
steps: list[DividerStep],
|
||||
road_line: LineString,
|
||||
main_polygon: Polygon,
|
||||
) -> LineString | None:
|
||||
"""분할선 스텝을 절단선으로 확장한다 — 도로 하류측과 능선 너머까지 관통.
|
||||
|
||||
split()은 절단선이 폴리곤 경계를 완전히 넘어야 동작하므로 양끝을 연장한다.
|
||||
스텝이 없으면(등고선 공백) 도로 법선 직선으로 폴백한다.
|
||||
"""
|
||||
bounds = main_polygon.bounds
|
||||
reach = CUT_RIDGE_EXTEND_RATIO * math.hypot(bounds[2] - bounds[0], bounds[3] - bounds[1])
|
||||
points = [(road_point.x, road_point.y)]
|
||||
points.extend((step.point.x, step.point.y) for step in steps if step.geom_index >= 0)
|
||||
if len(points) < 2:
|
||||
# 폴백: 도로 접선의 법선 방향으로 폴리곤을 관통하는 직선.
|
||||
t = road_line.project(road_point)
|
||||
a = road_line.interpolate(max(0.0, t - 5.0))
|
||||
b = road_line.interpolate(min(road_line.length, t + 5.0))
|
||||
dx, dy = b.x - a.x, b.y - a.y
|
||||
norm = math.hypot(dx, dy)
|
||||
if norm < 1e-6:
|
||||
return None
|
||||
nx, ny = -dy / norm, dx / norm
|
||||
head = (road_point.x + nx * reach, road_point.y + ny * reach)
|
||||
tail = (road_point.x - nx * reach, road_point.y - ny * reach)
|
||||
return LineString([tail, (road_point.x, road_point.y), head])
|
||||
# 능선 너머 연장: 마지막 진행 방향 유지.
|
||||
(px, py), (qx, qy) = points[-2], points[-1]
|
||||
dx, dy = qx - px, qy - py
|
||||
norm = math.hypot(dx, dy)
|
||||
if norm >= 1e-6:
|
||||
points.append((qx + dx / norm * reach, qy + dy / norm * reach))
|
||||
# 도로 하류측 연장: 첫 스텝 → 도로점 방향을 그대로 지나쳐 내려간다.
|
||||
(fx, fy) = points[1]
|
||||
dx, dy = road_point.x - fx, road_point.y - fy
|
||||
norm = math.hypot(dx, dy)
|
||||
if norm >= 1e-6:
|
||||
points.insert(
|
||||
0,
|
||||
(
|
||||
road_point.x + dx / norm * CUT_ROAD_TAIL_M,
|
||||
road_point.y + dy / norm * CUT_ROAD_TAIL_M,
|
||||
),
|
||||
)
|
||||
return LineString(points)
|
||||
|
||||
|
||||
def _interval_index(piece: Polygon, road_line: LineString, divide_ts: list[float]) -> int:
|
||||
"""조각이 어느 관 구간(k)에 속하는지 — 구간 도로와 맞닿는 길이가 최대인 곳.
|
||||
|
||||
대표점 투영은 대형 계곡 조각에서 오판한다(상류로 길게 뻗은 조각의 대표점이
|
||||
엉뚱한 구간에 떨어짐). 도로 접촉이 전혀 없는 조각만 대표점 투영으로 폴백.
|
||||
"""
|
||||
strip = piece.buffer(1.0)
|
||||
best_k, best_length = -1, 0.0
|
||||
for k in range(len(divide_ts) - 1):
|
||||
segment = substring(road_line, divide_ts[k], divide_ts[k + 1])
|
||||
if segment.is_empty:
|
||||
continue
|
||||
length = segment.intersection(strip).length
|
||||
if length > best_length:
|
||||
best_k, best_length = k, length
|
||||
if best_k >= 0:
|
||||
return best_k
|
||||
t = road_line.project(piece.representative_point())
|
||||
for k in range(len(divide_ts) - 1):
|
||||
if divide_ts[k] <= t <= divide_ts[k + 1]:
|
||||
return k
|
||||
return 0 if t < divide_ts[0] else len(divide_ts) - 2
|
||||
|
||||
|
||||
def subdivide_main_polygon(
|
||||
main_polygon: Polygon,
|
||||
divide_points: list[Point],
|
||||
dividers: list[list[DividerStep]],
|
||||
road_line: LineString,
|
||||
) -> list[Polygon | None]:
|
||||
"""메인 유역 폴리곤을 내부 분할선으로 쪼개 관 구간별 조각을 돌려준다.
|
||||
|
||||
반환 길이 = 관 개수(구간 수). 조각이 없는 구간은 None.
|
||||
split() 기반이므로 조각들은 배타적이고 합집합 == 메인 폴리곤이 보장된다.
|
||||
구간에 여러 조각이 잡히면(절단선 재진입) 모두 합쳐 가장 큰 폴리곤을 쓴다.
|
||||
"""
|
||||
interval_count = len(divide_points) - 1
|
||||
if interval_count <= 1:
|
||||
return [main_polygon]
|
||||
pieces: list[Polygon] = [main_polygon]
|
||||
for position in range(1, interval_count):
|
||||
cut = _cut_line(divide_points[position], dividers[position], road_line, main_polygon)
|
||||
if cut is None:
|
||||
logger.warning("분할 절단선 생성 실패(구간 %d) — 해당 분할을 건너뜁니다.", position)
|
||||
continue
|
||||
next_pieces: list[Polygon] = []
|
||||
for piece in pieces:
|
||||
try:
|
||||
parts = shapely_split(piece, cut)
|
||||
except Exception: # noqa: BLE001 - 절단 실패 시 조각 유지
|
||||
next_pieces.append(piece)
|
||||
continue
|
||||
split_parts = [
|
||||
part
|
||||
for part in getattr(parts, "geoms", [parts])
|
||||
if part.geom_type == "Polygon" and not part.is_empty
|
||||
]
|
||||
next_pieces.extend(split_parts if split_parts else [piece])
|
||||
pieces = next_pieces
|
||||
divide_ts = [road_line.project(point) for point in divide_points]
|
||||
assigned: list[list[Polygon]] = [[] for _ in range(interval_count)]
|
||||
for piece in pieces:
|
||||
assigned[_interval_index(piece, road_line, divide_ts)].append(piece)
|
||||
# 구간별 대표 조각 = 최대 조각과 그에 붙는 조각들. 비연결 잔여 조각은 버리지
|
||||
# 않고(합집합 불변 조건) 맞닿는 인접 구간으로 재배정한다.
|
||||
result: list[Polygon | None] = []
|
||||
leftovers: list[Polygon] = []
|
||||
for group in assigned:
|
||||
merged = _merge_touching(group)
|
||||
result.append(merged[0] if merged else None)
|
||||
leftovers.extend(merged[1:])
|
||||
for extra in leftovers:
|
||||
for position in sorted(
|
||||
range(interval_count),
|
||||
key=lambda k: extra.distance(result[k]) if result[k] is not None else math.inf,
|
||||
):
|
||||
base = result[position]
|
||||
if base is None or not extra.touches(base):
|
||||
continue
|
||||
candidate = base.union(extra).buffer(0)
|
||||
if candidate.geom_type == "Polygon":
|
||||
result[position] = candidate
|
||||
break
|
||||
else:
|
||||
logger.warning("세분화 잔여 조각(%.0f m²)을 재배정하지 못해 제외합니다.", extra.area)
|
||||
return result
|
||||
|
||||
|
||||
def _merge_touching(group: list[Polygon]) -> list[Polygon]:
|
||||
"""조각 묶음을 서로 맞닿는 것끼리 합쳐 면적 내림차순으로 돌려준다."""
|
||||
if not group:
|
||||
return []
|
||||
merged = unary_union(group).buffer(0)
|
||||
parts = list(merged.geoms) if merged.geom_type == "MultiPolygon" else [merged]
|
||||
parts = [part for part in parts if part.geom_type == "Polygon" and not part.is_empty]
|
||||
return sorted(parts, key=lambda part: part.area, reverse=True)
|
||||
@@ -206,10 +206,17 @@ async def post_drainage_basins(
|
||||
"status": "success",
|
||||
"project_id": str(project_id),
|
||||
"route_id": prepared["route_id"],
|
||||
# 계획선 위 배관(관 매설) 지점 — 유역이 없는 관도 마커로 표시해야 하므로 별도 목록.
|
||||
"pipes": [
|
||||
_candidate_payload(candidate, to_lonlat)
|
||||
for candidate in sorted(candidates, key=lambda item: item.chainage_m)
|
||||
],
|
||||
"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),
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type DrainageBasin,
|
||||
type RoutePoint,
|
||||
} from "./B05_wf2_Route_Api_Fetch";
|
||||
import { createPipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes";
|
||||
|
||||
// 배수유역도 패널 — 하단 종단 패널 안쪽 우측에 도킹되는 2단 사이드 패널.
|
||||
// 하단 패널이 닫히면 이 패널도 함께 화면에서 사라진다(부모 안에 들어 있으므로 자동).
|
||||
@@ -88,7 +89,24 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
analyzeButton.type = "button";
|
||||
analyzeButton.className = "b05-drainage__analyze";
|
||||
analyzeButton.textContent = "유역 산정";
|
||||
header.append(analyzeButton);
|
||||
// 배관 편집 토글 — 켜면 계획선 클릭으로 배관 추가, 마커 드래그로 이동.
|
||||
const editButton = document.createElement("button");
|
||||
editButton.type = "button";
|
||||
editButton.className = "b05-drainage__analyze b05-drainage__tool";
|
||||
editButton.textContent = "배관 편집";
|
||||
editButton.setAttribute("aria-pressed", "false");
|
||||
// 선택된 배관 삭제 — 편집 모드에서 마커를 선택해야 활성화된다.
|
||||
const deleteButton = document.createElement("button");
|
||||
deleteButton.type = "button";
|
||||
deleteButton.className = "b05-drainage__analyze b05-drainage__tool";
|
||||
deleteButton.textContent = "선택 삭제";
|
||||
deleteButton.disabled = true;
|
||||
// 자동 제안으로 되돌리기 — 편집한 배관 배치를 버리고 백엔드 자동 제안으로 재산정.
|
||||
const autoButton = document.createElement("button");
|
||||
autoButton.type = "button";
|
||||
autoButton.className = "b05-drainage__analyze b05-drainage__tool";
|
||||
autoButton.textContent = "자동 제안";
|
||||
header.append(analyzeButton, editButton, deleteButton, autoButton);
|
||||
|
||||
const viewport = document.createElement("div");
|
||||
viewport.className = "b05-drainage__viewport";
|
||||
@@ -117,6 +135,12 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
let normalizer: Normalizer | null = null;
|
||||
let basins: DrainageBasin[] = [];
|
||||
let selectedBasin: number | null = null;
|
||||
let editMode = false;
|
||||
// 배관 편집기 — 마커 선택/추가/이동/삭제 시 재그리기와 버튼 상태만 갱신한다.
|
||||
const pipeEditor = createPipeEditor(() => {
|
||||
syncPipeSelection();
|
||||
scheduleDraw();
|
||||
});
|
||||
// 유역 경계 외곽선 = 분수령(능선). 사용자 지시로 기본 표시.
|
||||
let showRidge = true;
|
||||
let scale = 1;
|
||||
@@ -217,9 +241,38 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
context.strokeStyle = ROUTE_COLOR;
|
||||
drawPreparedLayer(context, routeLayer, view, "dot");
|
||||
}
|
||||
// 배관(관 매설) 마커 — 계획선 위 최상단.
|
||||
pipeEditor.draw(context, view, pipeColor);
|
||||
updateImageTransform();
|
||||
}
|
||||
|
||||
/** 현재 프레임 뷰 상태 (draw()와 동일 계산 — 포인터 히트 판정용). */
|
||||
function currentView(): ViewState {
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
const width = Math.max(1, Math.floor(rect.width));
|
||||
const height = Math.max(1, Math.floor(rect.height));
|
||||
return { width, height, scale, offsetX, offsetY, mapRect: computeMapRect(meta, width, height) };
|
||||
}
|
||||
|
||||
/** 배관 마커 색 — 같은 누가거리 유역의 파스텔색(불투명). 유역이 없으면 회색. */
|
||||
function pipeColor(chainage: number): string {
|
||||
const basin = basins.find((item) => Math.abs(item.chainage_m - chainage) < 0.51);
|
||||
if (!basin) return "#e5e7eb";
|
||||
return BASIN_COLORS[(basin.index - 1) % BASIN_COLORS.length].replace(/0\.45\)$/, "1)");
|
||||
}
|
||||
|
||||
/** 마커 선택 ↔ 유역 목록 선택 동기화 + 삭제 버튼 활성화. */
|
||||
function syncPipeSelection(): void {
|
||||
const index = pipeEditor.selected();
|
||||
deleteButton.disabled = !editMode || index === null;
|
||||
const pipe = index === null ? null : pipeEditor.pipes()[index];
|
||||
const basin = pipe
|
||||
? basins.find((item) => Math.abs(item.chainage_m - pipe.chainage_m) < 0.51)
|
||||
: null;
|
||||
selectedBasin = basin ? basin.index : null;
|
||||
renderBasinList();
|
||||
}
|
||||
|
||||
function scheduleDraw(): void {
|
||||
if (frameHandle) return;
|
||||
frameHandle = window.requestAnimationFrame(() => {
|
||||
@@ -263,17 +316,27 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
return areaM2 >= 10000 ? `${(areaM2 / 10000).toFixed(2)}ha` : `${Math.round(areaM2)}㎡`;
|
||||
}
|
||||
|
||||
/** 구조물 측점 후보 제안 + 유역 산정을 백엔드에 요청한다(계산은 전부 백엔드). */
|
||||
async function analyze(): Promise<void> {
|
||||
/** 구조물 측점 후보 제안 + 유역 산정을 백엔드에 요청한다(계산은 전부 백엔드).
|
||||
* 편집된 배관이 있으면 그 누가거리로 확정 산정하고, auto=true면 자동 제안으로 되돌린다. */
|
||||
async function analyze(auto = false): Promise<void> {
|
||||
if (!projectId) return;
|
||||
analyzeButton.disabled = true;
|
||||
status.hidden = false;
|
||||
status.textContent = "배수유역을 산정하는 중…";
|
||||
try {
|
||||
const response = await fetchDrainageBasins(projectId);
|
||||
const chainages = !auto && pipeEditor.pipes().length > 0 ? pipeEditor.chainages() : undefined;
|
||||
const response = await fetchDrainageBasins(projectId, chainages);
|
||||
basins = response.basins;
|
||||
// 실제 사용된 배관 목록으로 마커를 동기화한다(유역 없는 관 포함).
|
||||
pipeEditor.setPipes(
|
||||
(response.pipes ?? []).map((pipe) => ({
|
||||
chainage_m: pipe.chainage_m,
|
||||
reason: pipe.reason,
|
||||
})),
|
||||
);
|
||||
selectedBasin = null;
|
||||
renderBasinList();
|
||||
syncPipeSelection();
|
||||
status.hidden = basins.length > 0;
|
||||
if (basins.length === 0) status.textContent = "산정된 배수유역이 없습니다.";
|
||||
scheduleDraw();
|
||||
@@ -286,6 +349,17 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
}
|
||||
|
||||
analyzeButton.addEventListener("click", () => void analyze());
|
||||
editButton.addEventListener("click", () => {
|
||||
editMode = !editMode;
|
||||
editButton.classList.toggle("is-active", editMode);
|
||||
editButton.setAttribute("aria-pressed", String(editMode));
|
||||
syncPipeSelection();
|
||||
});
|
||||
deleteButton.addEventListener("click", () => void pipeEditor.deleteSelected());
|
||||
autoButton.addEventListener("click", () => {
|
||||
pipeEditor.setPipes([]);
|
||||
void analyze(true);
|
||||
});
|
||||
|
||||
/** 노선 전체가 보이도록 배율·중심을 맞춘다. 노선이 없으면 도엽 전체를 그대로 보여준다. */
|
||||
function fitToRoute(): void {
|
||||
@@ -351,6 +425,7 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
});
|
||||
backgroundImage.src = `${getVWorldMapUrl(activeProjectId, "satellite")}&_t=${Date.now()}`;
|
||||
if (routePoints.length > 1) routeLayer = prepareMetricPolyline(routePoints, nextMeta);
|
||||
pipeEditor.setContext(nextMeta, routePoints);
|
||||
status.hidden = featureCount > 0;
|
||||
if (featureCount === 0) status.textContent = "도엽 레이어가 없습니다. B04에서 임포트하세요.";
|
||||
fitToRoute();
|
||||
@@ -382,16 +457,34 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
viewport.addEventListener("pointerdown", (event) => {
|
||||
// 중간 버튼 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹치지 않게 막는다.
|
||||
if (event.button === 1) event.preventDefault();
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
// 배관 마커 클릭/추가가 처리되면 지도 팬은 시작하지 않는다.
|
||||
if (
|
||||
pipeEditor.handleDown(
|
||||
currentView(),
|
||||
event.clientX - rect.left,
|
||||
event.clientY - rect.top,
|
||||
editMode,
|
||||
)
|
||||
) {
|
||||
viewport.setPointerCapture(event.pointerId);
|
||||
return;
|
||||
}
|
||||
dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY };
|
||||
viewport.setPointerCapture(event.pointerId);
|
||||
});
|
||||
viewport.addEventListener("pointermove", (event) => {
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
// 배관 드래그 중이면 마커 이동(계획선 스냅)만 처리한다.
|
||||
if (pipeEditor.handleMove(currentView(), event.clientX - rect.left, event.clientY - rect.top))
|
||||
return;
|
||||
if (!dragStart) return;
|
||||
offsetX = dragStart.offsetX + event.clientX - dragStart.x;
|
||||
offsetY = dragStart.offsetY + event.clientY - dragStart.y;
|
||||
scheduleDraw();
|
||||
});
|
||||
const stopDragging = (): void => {
|
||||
pipeEditor.handleUp();
|
||||
dragStart = null;
|
||||
};
|
||||
viewport.addEventListener("pointerup", stopDragging);
|
||||
@@ -421,6 +514,7 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
setRoute(points) {
|
||||
routePoints = points;
|
||||
routeLayer = meta && points.length > 1 ? prepareMetricPolyline(points, meta) : null;
|
||||
pipeEditor.setContext(meta, points);
|
||||
if (routeLayer) fitToRoute();
|
||||
scheduleDraw();
|
||||
},
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import type { VWorldMeta } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch";
|
||||
import type { ViewState } from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender";
|
||||
|
||||
// 배관(관 매설) 지점 편집기 — 배수유역 패널의 계획선 위 마커 표시·추가·이동·삭제.
|
||||
// 마커 위치의 단일 소스는 누가거리(chainage)다. 화면 좌표는 매 프레임 노선
|
||||
// 폴리라인(사업지 좌표계 m)을 따라 보간해 구하므로 확대/이동과 무관하게 정확하다.
|
||||
|
||||
/** 배관 지점 1개. reason: stream(세류 교차)/spacing(300m 보충)/confirmed(사용자 확정). */
|
||||
export interface PipePoint {
|
||||
chainage_m: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface RoutePointLike {
|
||||
x: number;
|
||||
y: number;
|
||||
chainage_m?: number;
|
||||
}
|
||||
|
||||
/** 마커 히트 판정 반경(px)과 계획선 추가 클릭 허용 거리(px). */
|
||||
const HIT_RADIUS_PX = 12;
|
||||
const ADD_SNAP_PX = 14;
|
||||
|
||||
export interface PipeEditor {
|
||||
setContext(meta: VWorldMeta | null, points: ReadonlyArray<RoutePointLike>): void;
|
||||
setPipes(pipes: ReadonlyArray<PipePoint>): void;
|
||||
pipes(): ReadonlyArray<PipePoint>;
|
||||
chainages(): number[];
|
||||
selected(): number | null;
|
||||
select(index: number | null): void;
|
||||
deleteSelected(): boolean;
|
||||
/** 편집 상호작용. 처리했으면 true(패널은 지도 팬을 생략한다). */
|
||||
handleDown(view: ViewState, screenX: number, screenY: number, editMode: boolean): boolean;
|
||||
handleMove(view: ViewState, screenX: number, screenY: number): boolean;
|
||||
handleUp(): boolean;
|
||||
draw(
|
||||
context: CanvasRenderingContext2D,
|
||||
view: ViewState,
|
||||
colorOf: (chainage: number, position: number) => string,
|
||||
): void;
|
||||
}
|
||||
|
||||
export function createPipeEditor(onChange: () => void): PipeEditor {
|
||||
let meta: VWorldMeta | null = null;
|
||||
let route: Array<{ x: number; y: number; chainage: number }> = [];
|
||||
let totalChainage = 0;
|
||||
let pipeList: PipePoint[] = [];
|
||||
let selectedIndex: number | null = null;
|
||||
let draggingIndex: number | null = null;
|
||||
let dragMoved = false;
|
||||
|
||||
/** 화면 → 사업지 좌표계 m (MapRender affine의 역변환). */
|
||||
function screenToMetric(
|
||||
view: ViewState,
|
||||
sx: number,
|
||||
sy: number,
|
||||
): { x: number; y: number } | null {
|
||||
if (!meta) return null;
|
||||
const ax = view.mapRect.width * view.scale;
|
||||
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
|
||||
const ay = view.mapRect.height * view.scale;
|
||||
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
|
||||
if (!ax || !ay) return null;
|
||||
const nx = (sx - bx) / ax;
|
||||
const ny = (sy - by) / ay;
|
||||
return {
|
||||
x: meta.x_min + nx * (meta.width_meters || 1),
|
||||
y: meta.y_min + (1 - ny) * (meta.height_meters || 1),
|
||||
};
|
||||
}
|
||||
|
||||
function metricToScreen(view: ViewState, x: number, y: number): { x: number; y: number } | null {
|
||||
if (!meta) return null;
|
||||
const nx = (x - meta.x_min) / (meta.width_meters || 1);
|
||||
const ny = 1 - (y - meta.y_min) / (meta.height_meters || 1);
|
||||
const ax = view.mapRect.width * view.scale;
|
||||
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
|
||||
const ay = view.mapRect.height * view.scale;
|
||||
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
|
||||
return { x: nx * ax + bx, y: ny * ay + by };
|
||||
}
|
||||
|
||||
/** 1m가 화면에서 몇 px인지 (거리 판정용). */
|
||||
function pxPerMeter(view: ViewState): number {
|
||||
if (!meta) return 1;
|
||||
return (view.mapRect.width * view.scale) / (meta.width_meters || 1);
|
||||
}
|
||||
|
||||
function chainageToXY(chainage: number): { x: number; y: number } | null {
|
||||
if (route.length < 2) return null;
|
||||
if (chainage <= route[0].chainage) return { x: route[0].x, y: route[0].y };
|
||||
for (let i = 1; i < route.length; i += 1) {
|
||||
const prev = route[i - 1];
|
||||
const next = route[i];
|
||||
if (chainage > next.chainage) continue;
|
||||
const span = next.chainage - prev.chainage || 1;
|
||||
const t = (chainage - prev.chainage) / span;
|
||||
return { x: prev.x + (next.x - prev.x) * t, y: prev.y + (next.y - prev.y) * t };
|
||||
}
|
||||
const last = route[route.length - 1];
|
||||
return { x: last.x, y: last.y };
|
||||
}
|
||||
|
||||
/** 사업지 좌표에서 노선 최근접 지점의 누가거리와 이탈 거리(m). */
|
||||
function nearestChainage(x: number, y: number): { chainage: number; distance: number } | null {
|
||||
if (route.length < 2) return null;
|
||||
let best: { chainage: number; distance: number } | null = null;
|
||||
for (let i = 1; i < route.length; i += 1) {
|
||||
const a = route[i - 1];
|
||||
const b = route[i];
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const lengthSq = dx * dx + dy * dy || 1;
|
||||
const t = Math.max(0, Math.min(1, ((x - a.x) * dx + (y - a.y) * dy) / lengthSq));
|
||||
const px = a.x + dx * t;
|
||||
const py = a.y + dy * t;
|
||||
const distance = Math.hypot(x - px, y - py);
|
||||
const chainage = a.chainage + (b.chainage - a.chainage) * t;
|
||||
if (!best || distance < best.distance) best = { chainage, distance };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function sortPipes(): void {
|
||||
const selected = selectedIndex === null ? null : pipeList[selectedIndex];
|
||||
pipeList.sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
selectedIndex = selected === null ? null : pipeList.indexOf(selected);
|
||||
}
|
||||
|
||||
return {
|
||||
setContext(nextMeta, points) {
|
||||
meta = nextMeta;
|
||||
let cumulative = 0;
|
||||
route = points.map((point, index) => {
|
||||
if (index > 0) {
|
||||
const prev = points[index - 1];
|
||||
cumulative += Math.hypot(point.x - prev.x, point.y - prev.y);
|
||||
}
|
||||
return { x: point.x, y: point.y, chainage: point.chainage_m ?? cumulative };
|
||||
});
|
||||
totalChainage = route.length > 0 ? route[route.length - 1].chainage : 0;
|
||||
},
|
||||
setPipes(pipes) {
|
||||
pipeList = pipes.map((pipe) => ({ ...pipe }));
|
||||
sortPipes();
|
||||
selectedIndex = null;
|
||||
draggingIndex = null;
|
||||
},
|
||||
pipes: () => pipeList,
|
||||
chainages: () => pipeList.map((pipe) => Math.round(pipe.chainage_m * 100) / 100),
|
||||
selected: () => selectedIndex,
|
||||
select(index) {
|
||||
selectedIndex = index;
|
||||
},
|
||||
deleteSelected() {
|
||||
if (selectedIndex === null) return false;
|
||||
pipeList.splice(selectedIndex, 1);
|
||||
selectedIndex = null;
|
||||
onChange();
|
||||
return true;
|
||||
},
|
||||
handleDown(view, screenX, screenY, editMode) {
|
||||
dragMoved = false;
|
||||
// 마커 클릭: 선택 (편집 모드 여부 무관), 편집 모드면 드래그 시작.
|
||||
for (let i = pipeList.length - 1; i >= 0; i -= 1) {
|
||||
const xy = chainageToXY(pipeList[i].chainage_m);
|
||||
if (!xy) continue;
|
||||
const screen = metricToScreen(view, xy.x, xy.y);
|
||||
if (!screen) continue;
|
||||
if (Math.hypot(screenX - screen.x, screenY - screen.y) <= HIT_RADIUS_PX) {
|
||||
selectedIndex = i;
|
||||
if (editMode) draggingIndex = i;
|
||||
onChange();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!editMode) return false;
|
||||
// 계획선 클릭: 그 지점에 배관 추가.
|
||||
const metric = screenToMetric(view, screenX, screenY);
|
||||
if (!metric) return false;
|
||||
const nearest = nearestChainage(metric.x, metric.y);
|
||||
if (!nearest || nearest.distance * pxPerMeter(view) > ADD_SNAP_PX) return false;
|
||||
pipeList.push({ chainage_m: nearest.chainage, reason: "confirmed" });
|
||||
sortPipes();
|
||||
selectedIndex = pipeList.findIndex(
|
||||
(pipe) => Math.abs(pipe.chainage_m - nearest.chainage) < 1e-6,
|
||||
);
|
||||
draggingIndex = selectedIndex;
|
||||
onChange();
|
||||
return true;
|
||||
},
|
||||
handleMove(view, screenX, screenY) {
|
||||
if (draggingIndex === null) return false;
|
||||
const metric = screenToMetric(view, screenX, screenY);
|
||||
if (!metric) return true;
|
||||
const nearest = nearestChainage(metric.x, metric.y);
|
||||
if (!nearest) return true;
|
||||
const clamped = Math.max(0, Math.min(totalChainage, nearest.chainage));
|
||||
pipeList[draggingIndex].chainage_m = clamped;
|
||||
pipeList[draggingIndex].reason = "confirmed";
|
||||
dragMoved = true;
|
||||
onChange();
|
||||
return true;
|
||||
},
|
||||
handleUp() {
|
||||
if (draggingIndex === null) return false;
|
||||
draggingIndex = null;
|
||||
if (dragMoved) {
|
||||
sortPipes();
|
||||
onChange();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
draw(context, view, colorOf) {
|
||||
pipeList.forEach((pipe, position) => {
|
||||
const xy = chainageToXY(pipe.chainage_m);
|
||||
if (!xy) return;
|
||||
const screen = metricToScreen(view, xy.x, xy.y);
|
||||
if (!screen) return;
|
||||
const isSelected = position === selectedIndex;
|
||||
const radius = isSelected ? 9 : 7;
|
||||
context.beginPath();
|
||||
context.arc(screen.x, screen.y, radius, 0, Math.PI * 2);
|
||||
context.fillStyle = colorOf(pipe.chainage_m, position);
|
||||
context.fill();
|
||||
context.lineWidth = isSelected ? 2.5 : 1.5;
|
||||
context.strokeStyle = isSelected ? "#111827" : "#374151";
|
||||
context.stroke();
|
||||
context.fillStyle = "#111827";
|
||||
context.font = "bold 10px sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.fillText(String(position + 1), screen.x, screen.y);
|
||||
// 누가거리 라벨 — 마커 우상단.
|
||||
context.font = "10px sans-serif";
|
||||
context.textAlign = "left";
|
||||
context.fillStyle = "#1f2937";
|
||||
context.fillText(
|
||||
`${pipe.chainage_m.toFixed(0)}m`,
|
||||
screen.x + radius + 3,
|
||||
screen.y - radius,
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -948,6 +948,21 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 배관 편집 도구 버튼 — "유역 산정" 우측에 나란히(auto 마진 해제). */
|
||||
.b05-drainage__tool {
|
||||
margin-left: var(--spacing-4, 4px);
|
||||
}
|
||||
|
||||
/* 배관 편집 토글 활성 상태. */
|
||||
.b05-drainage__analyze.is-active {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-royal-amethyst, rgb(109 40 217)) 18%,
|
||||
var(--color-surface)
|
||||
);
|
||||
border-color: var(--color-royal-amethyst, rgb(109 40 217));
|
||||
}
|
||||
|
||||
/* 유역 제원 목록 — 면적·유역표고·유하거리·관경(수식 확정 전까지 "미정"). */
|
||||
.b05-drainage__basins {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user