From 12f166650618104dc0d1a44db61a12476c96ff22 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 30 Jul 2026 17:25:13 +0900 Subject: [PATCH] auto: 2026-07-30 17:25 (EOMSANGDON-HOME) --- B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts | 4 + ...B05_wf2_Route_Engine_Drainage_Watershed.py | 488 ++++++++---------- ...B05_wf2_Route_Engine_Watershed_Assemble.py | 205 ++++++++ ...05_wf2_Route_Engine_Watershed_Subdivide.py | 171 ++++++ .../B05_wf2_Route_Router_Drainage.py | 7 + .../B05_wf2_Route_UI_Drainage_Panel.ts | 102 +++- .../B05_wf2_Route_UI_Drainage_Pipes.ts | 246 +++++++++ B05_wf2_Route/B05_wf2_Route_UI_Style.css | 15 + 8 files changed, 953 insertions(+), 285 deletions(-) create mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Assemble.py create mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Subdivide.py create mode 100644 B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index e6c23a42..aff1d624 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -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[]; } diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py index 3b2f12ba..1966d8b8 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py @@ -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 diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Assemble.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Assemble.py new file mode 100644 index 00000000..8bff4e40 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Assemble.py @@ -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 diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Subdivide.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Subdivide.py new file mode 100644 index 00000000..8efdb68f --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Subdivide.py @@ -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) diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py index 71979888..f489eed8 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py @@ -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), diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index ac1f419a..8f6a6391 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -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 { + /** 구조물 측점 후보 제안 + 유역 산정을 백엔드에 요청한다(계산은 전부 백엔드). + * 편집된 배관이 있으면 그 누가거리로 확정 산정하고, auto=true면 자동 제안으로 되돌린다. */ + async function analyze(auto = false): Promise { 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(); }, diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts new file mode 100644 index 00000000..a7ab7101 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts @@ -0,0 +1,246 @@ +import type { VWorldMeta } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; +import type { ViewState } from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender"; + +// 배관(관 매설) 지점 편집기 — 배수유역 패널의 계획선 위 마커 표시·추가·이동·삭제. +// 마커 위치의 단일 소스는 누가거리(chainage)다. 화면 좌표는 매 프레임 노선 +// 폴리라인(사업지 좌표계 m)을 따라 보간해 구하므로 확대/이동과 무관하게 정확하다. + +/** 배관 지점 1개. reason: stream(세류 교차)/spacing(300m 보충)/confirmed(사용자 확정). */ +export interface PipePoint { + chainage_m: number; + reason: string; +} + +interface RoutePointLike { + x: number; + y: number; + chainage_m?: number; +} + +/** 마커 히트 판정 반경(px)과 계획선 추가 클릭 허용 거리(px). */ +const HIT_RADIUS_PX = 12; +const ADD_SNAP_PX = 14; + +export interface PipeEditor { + setContext(meta: VWorldMeta | null, points: ReadonlyArray): void; + setPipes(pipes: ReadonlyArray): void; + pipes(): ReadonlyArray; + chainages(): number[]; + selected(): number | null; + select(index: number | null): void; + deleteSelected(): boolean; + /** 편집 상호작용. 처리했으면 true(패널은 지도 팬을 생략한다). */ + handleDown(view: ViewState, screenX: number, screenY: number, editMode: boolean): boolean; + handleMove(view: ViewState, screenX: number, screenY: number): boolean; + handleUp(): boolean; + draw( + context: CanvasRenderingContext2D, + view: ViewState, + colorOf: (chainage: number, position: number) => string, + ): void; +} + +export function createPipeEditor(onChange: () => void): PipeEditor { + let meta: VWorldMeta | null = null; + let route: Array<{ x: number; y: number; chainage: number }> = []; + let totalChainage = 0; + let pipeList: PipePoint[] = []; + let selectedIndex: number | null = null; + let draggingIndex: number | null = null; + let dragMoved = false; + + /** 화면 → 사업지 좌표계 m (MapRender affine의 역변환). */ + function screenToMetric( + view: ViewState, + sx: number, + sy: number, + ): { x: number; y: number } | null { + if (!meta) return null; + const ax = view.mapRect.width * view.scale; + const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; + const ay = view.mapRect.height * view.scale; + const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; + if (!ax || !ay) return null; + const nx = (sx - bx) / ax; + const ny = (sy - by) / ay; + return { + x: meta.x_min + nx * (meta.width_meters || 1), + y: meta.y_min + (1 - ny) * (meta.height_meters || 1), + }; + } + + function metricToScreen(view: ViewState, x: number, y: number): { x: number; y: number } | null { + if (!meta) return null; + const nx = (x - meta.x_min) / (meta.width_meters || 1); + const ny = 1 - (y - meta.y_min) / (meta.height_meters || 1); + const ax = view.mapRect.width * view.scale; + const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; + const ay = view.mapRect.height * view.scale; + const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; + return { x: nx * ax + bx, y: ny * ay + by }; + } + + /** 1m가 화면에서 몇 px인지 (거리 판정용). */ + function pxPerMeter(view: ViewState): number { + if (!meta) return 1; + return (view.mapRect.width * view.scale) / (meta.width_meters || 1); + } + + function chainageToXY(chainage: number): { x: number; y: number } | null { + if (route.length < 2) return null; + if (chainage <= route[0].chainage) return { x: route[0].x, y: route[0].y }; + for (let i = 1; i < route.length; i += 1) { + const prev = route[i - 1]; + const next = route[i]; + if (chainage > next.chainage) continue; + const span = next.chainage - prev.chainage || 1; + const t = (chainage - prev.chainage) / span; + return { x: prev.x + (next.x - prev.x) * t, y: prev.y + (next.y - prev.y) * t }; + } + const last = route[route.length - 1]; + return { x: last.x, y: last.y }; + } + + /** 사업지 좌표에서 노선 최근접 지점의 누가거리와 이탈 거리(m). */ + function nearestChainage(x: number, y: number): { chainage: number; distance: number } | null { + if (route.length < 2) return null; + let best: { chainage: number; distance: number } | null = null; + for (let i = 1; i < route.length; i += 1) { + const a = route[i - 1]; + const b = route[i]; + const dx = b.x - a.x; + const dy = b.y - a.y; + const lengthSq = dx * dx + dy * dy || 1; + const t = Math.max(0, Math.min(1, ((x - a.x) * dx + (y - a.y) * dy) / lengthSq)); + const px = a.x + dx * t; + const py = a.y + dy * t; + const distance = Math.hypot(x - px, y - py); + const chainage = a.chainage + (b.chainage - a.chainage) * t; + if (!best || distance < best.distance) best = { chainage, distance }; + } + return best; + } + + function sortPipes(): void { + const selected = selectedIndex === null ? null : pipeList[selectedIndex]; + pipeList.sort((a, b) => a.chainage_m - b.chainage_m); + selectedIndex = selected === null ? null : pipeList.indexOf(selected); + } + + return { + setContext(nextMeta, points) { + meta = nextMeta; + let cumulative = 0; + route = points.map((point, index) => { + if (index > 0) { + const prev = points[index - 1]; + cumulative += Math.hypot(point.x - prev.x, point.y - prev.y); + } + return { x: point.x, y: point.y, chainage: point.chainage_m ?? cumulative }; + }); + totalChainage = route.length > 0 ? route[route.length - 1].chainage : 0; + }, + setPipes(pipes) { + pipeList = pipes.map((pipe) => ({ ...pipe })); + sortPipes(); + selectedIndex = null; + draggingIndex = null; + }, + pipes: () => pipeList, + chainages: () => pipeList.map((pipe) => Math.round(pipe.chainage_m * 100) / 100), + selected: () => selectedIndex, + select(index) { + selectedIndex = index; + }, + deleteSelected() { + if (selectedIndex === null) return false; + pipeList.splice(selectedIndex, 1); + selectedIndex = null; + onChange(); + return true; + }, + handleDown(view, screenX, screenY, editMode) { + dragMoved = false; + // 마커 클릭: 선택 (편집 모드 여부 무관), 편집 모드면 드래그 시작. + for (let i = pipeList.length - 1; i >= 0; i -= 1) { + const xy = chainageToXY(pipeList[i].chainage_m); + if (!xy) continue; + const screen = metricToScreen(view, xy.x, xy.y); + if (!screen) continue; + if (Math.hypot(screenX - screen.x, screenY - screen.y) <= HIT_RADIUS_PX) { + selectedIndex = i; + if (editMode) draggingIndex = i; + onChange(); + return true; + } + } + if (!editMode) return false; + // 계획선 클릭: 그 지점에 배관 추가. + const metric = screenToMetric(view, screenX, screenY); + if (!metric) return false; + const nearest = nearestChainage(metric.x, metric.y); + if (!nearest || nearest.distance * pxPerMeter(view) > ADD_SNAP_PX) return false; + pipeList.push({ chainage_m: nearest.chainage, reason: "confirmed" }); + sortPipes(); + selectedIndex = pipeList.findIndex( + (pipe) => Math.abs(pipe.chainage_m - nearest.chainage) < 1e-6, + ); + draggingIndex = selectedIndex; + onChange(); + return true; + }, + handleMove(view, screenX, screenY) { + if (draggingIndex === null) return false; + const metric = screenToMetric(view, screenX, screenY); + if (!metric) return true; + const nearest = nearestChainage(metric.x, metric.y); + if (!nearest) return true; + const clamped = Math.max(0, Math.min(totalChainage, nearest.chainage)); + pipeList[draggingIndex].chainage_m = clamped; + pipeList[draggingIndex].reason = "confirmed"; + dragMoved = true; + onChange(); + return true; + }, + handleUp() { + if (draggingIndex === null) return false; + draggingIndex = null; + if (dragMoved) { + sortPipes(); + onChange(); + } + return true; + }, + draw(context, view, colorOf) { + pipeList.forEach((pipe, position) => { + const xy = chainageToXY(pipe.chainage_m); + if (!xy) return; + const screen = metricToScreen(view, xy.x, xy.y); + if (!screen) return; + const isSelected = position === selectedIndex; + const radius = isSelected ? 9 : 7; + context.beginPath(); + context.arc(screen.x, screen.y, radius, 0, Math.PI * 2); + context.fillStyle = colorOf(pipe.chainage_m, position); + context.fill(); + context.lineWidth = isSelected ? 2.5 : 1.5; + context.strokeStyle = isSelected ? "#111827" : "#374151"; + context.stroke(); + context.fillStyle = "#111827"; + context.font = "bold 10px sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText(String(position + 1), screen.x, screen.y); + // 누가거리 라벨 — 마커 우상단. + context.font = "10px sans-serif"; + context.textAlign = "left"; + context.fillStyle = "#1f2937"; + context.fillText( + `${pipe.chainage_m.toFixed(0)}m`, + screen.x + radius + 3, + screen.y - radius, + ); + }); + }, + }; +} diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index ad2e6289..85d09e4d 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -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;