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
+18 -3
View File
@@ -161,7 +161,12 @@ def _payload(
"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],
# 조각·구멍을 모두 편 링 목록 — 도넛 유역과 떨어진 조각을 그대로 그린다.
"polygon_rings_lonlat": [
[list(to_lonlat(x, y)) for x, y in ring] for ring in basin.boundary_rings
],
"area_m2": round(basin.area_m2, 1),
"relief_m": round(basin.relief_m, 2),
"flow_length_m": round(basin.flow_length_m, 1),
@@ -192,9 +197,19 @@ def _basin_features(
to_lonlat = context.to_lonlat
features: list[dict[str, Any]] = []
for basin in detail.basins:
ring = [list(to_lonlat(x, y)) for x, y in basin.boundary_xy]
if len(ring) < 4:
# GeoJSON 규격 그대로 — 조각마다 [외곽, 구멍...], 조각이 여럿이면 MultiPolygon.
parts = [
[[list(to_lonlat(x, y)) for x, y in ring] for ring in part if len(ring) >= 4]
for part in basin.boundary_parts
]
parts = [part for part in parts if part]
if not parts:
continue
geometry = (
{"type": "Polygon", "coordinates": parts[0]}
if len(parts) == 1
else {"type": "MultiPolygon", "coordinates": parts}
)
features.append(
{
"type": "Feature",
@@ -213,7 +228,7 @@ def _basin_features(
"design_flow_m3s": basin.design_flow_m3s,
"bridge_required": basin.bridge_required,
},
"geometry": {"type": "Polygon", "coordinates": [ring]},
"geometry": geometry,
}
)
for pipe, point in zip(detail.pipes, points):