feat(B04): 세부유역 경계를 링 목록(외곽+구멍)으로 넓혀 중첩·빈공간 해소

단일 링 자료형이 곧 중첩·빈공간이었다. 아래 유역이 위 유역을 감싸면 구멍이 사라져 위
유역을 통째로 덮고(합성 실측 256㎡ 전량), 한 관의 유역이 두 조각이면 작은 쪽이 사라져
빈공간이 됐다(144㎡).

- `polygon_parts()` 신설 — 조각마다 [외곽 링, 구멍 링...], 넓은 조각부터.
- `WatershedBasin.boundary_parts` 로 교체. `boundary_xy`(가장 넓은 조각의 외곽)와
  `boundary_rings`(편 링 목록)는 파생 속성이라 옛 소비처는 그대로 동작한다.
- API 에 `polygon_rings_lonlat` 추가. 저장 GeoJSON 은 구멍을 가진 Polygon, 조각이
  여럿이면 MultiPolygon.
- 캔버스 4곳(B04 유역화면 채움·선택, B05 배수유역도 채움·선택)은 even-odd 로 한 번에
  채우고 `pointInRings()` 로 판정 — 구멍 안을 눌러도 바깥 유역이 잡히지 않는다.
  중복이던 지역 `pointInRing` 은 삭제하고 공용 것으로 통일.

검증: 단위 5건 신규(도넛 구멍 보존·조각 2개 보존·파생 속성·빈 경계·조립 경로 전체에서
링 2개), 전체 168 passed, typecheck 통과. 브라우저 — API 가 유역 22개에 링 목록을 실어
보내고, 캔버스 even-odd 실측(가운데 알파 0 / 고리 255)으로 구멍이 실제로 뚫린다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-03 12:40:21 +09:00
co-authored by Claude Opus 5
parent d140d04766
commit f1bcee7857
8 changed files with 116 additions and 35 deletions
+16 -3
View File
@@ -34,7 +34,7 @@ import numpy as np
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import find_inflow_hotspots
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import STAGES
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Flow import largest_ring, polygonize_labels
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Flow import polygon_parts, polygonize_labels
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import GridSpec
from common_util.common_util_drainage_pipes import (
PIPE_FACILITY_BOX,
@@ -104,7 +104,10 @@ class WatershedBasin:
chainage_m: float
outlet_x: float
outlet_y: float
boundary_xy: list[tuple[float, float]] = field(default_factory=list)
# 유역 경계 — 조각마다 [외곽 링, 구멍 링...]. 도넛(아래 유역이 위 유역을 감싼 경우)과
# 떨어진 조각을 그대로 싣는다. 단일 링만 쓰던 시절에는 이 둘이 소실돼 화면에서 중첩·
# 빈공간으로 보였다(2026-09-03).
boundary_parts: list[list[list[tuple[float, float]]]] = field(default_factory=list)
area_m2: float = 0.0
relief_m: float = 0.0
flow_length_m: float = 0.0
@@ -123,6 +126,16 @@ class WatershedBasin:
recommended_facility: str = "pipe"
recommended_diameter_mm: int | None = None
@property
def boundary_xy(self) -> list[tuple[float, float]]:
"""가장 넓은 조각의 외곽 링 — 링 하나만 받는 옛 소비처를 위한 자리."""
return self.boundary_parts[0][0] if self.boundary_parts else []
@property
def boundary_rings(self) -> list[list[tuple[float, float]]]:
"""조각 구분 없이 편 링 목록 — 캔버스는 even-odd로 한 번에 채운다."""
return [ring for part in self.boundary_parts for ring in part]
@dataclass
class RoadRouting:
@@ -537,7 +550,7 @@ def assemble_basins(
chainage_m=pipe.chainage_m,
outlet_x=pipe.x,
outlet_y=pipe.y,
boundary_xy=largest_ring(geometry) if geometry is not None else [],
boundary_parts=polygon_parts(geometry) if geometry is not None else [],
area_m2=area,
relief_m=relief,
flow_length_m=flow_length,