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:
@@ -500,7 +500,10 @@ export interface DetailBasin {
|
||||
index: number;
|
||||
chainage_m: number;
|
||||
outlet_lonlat: [number, number];
|
||||
/** 가장 넓은 조각의 외곽 링 하나 — 중심 계산처럼 링 하나면 되는 자리에 쓴다. */
|
||||
polygon_lonlat: Array<[number, number]>;
|
||||
/** 조각·구멍을 모두 편 링 목록. 도넛 유역과 떨어진 조각을 그대로 그린다(even-odd). */
|
||||
polygon_rings_lonlat?: Array<Array<[number, number]>>;
|
||||
area_m2: number;
|
||||
relief_m: number;
|
||||
flow_length_m: number;
|
||||
|
||||
@@ -560,6 +560,25 @@ def polygonize_labels(
|
||||
return merged
|
||||
|
||||
|
||||
def polygon_parts(geometry: Polygon | MultiPolygon) -> list[list[list[tuple[float, float]]]]:
|
||||
"""폴리곤을 **조각 목록**으로 편다 — 조각마다 [외곽 링, 구멍 링...] 순.
|
||||
|
||||
`largest_ring()`은 가장 큰 조각의 외곽 하나만 낸다. 세부유역에서는 그 자리가 곧
|
||||
중첩·빈공간이었다(2026-09-03 합성 실측): 아래 유역이 위 유역을 감싸면 구멍이 사라져
|
||||
위 유역 256㎡가 통째로 덮이고, 한 관의 유역이 두 조각(225㎡+144㎡)이면 작은 144㎡가
|
||||
빠져 빈공간이 됐다. 조각은 넓은 것부터, 좌표는 조각 안에서 외곽 다음에 구멍이다.
|
||||
"""
|
||||
if geometry.is_empty:
|
||||
return []
|
||||
parts = list(geometry.geoms) if geometry.geom_type == "MultiPolygon" else [geometry]
|
||||
result: list[list[list[tuple[float, float]]]] = []
|
||||
for part in sorted(parts, key=lambda item: item.area, reverse=True):
|
||||
rings = [[(float(x), float(y)) for x, y in part.exterior.coords]]
|
||||
rings.extend([(float(x), float(y)) for x, y in hole.coords] for hole in part.interiors)
|
||||
result.append(rings)
|
||||
return result
|
||||
|
||||
|
||||
def largest_ring(geometry: Polygon | MultiPolygon) -> list[tuple[float, float]]:
|
||||
"""폴리곤(또는 멀티폴리곤)에서 가장 큰 조각의 외곽 링 좌표를 뽑는다."""
|
||||
if geometry.is_empty:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
* 확정 전에 화면을 떠나면 저장된 값으로 되돌아온다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { pointInRings } from "./B04_PreProcess_UI_MapOverlays";
|
||||
import { createMapContextMenu } from "@ui/ui_template_context_menu";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
@@ -71,17 +72,6 @@ const BASIN_ALPHA_PLAIN = 0.22;
|
||||
const BASIN_ALPHA_SELECTED = 0.38;
|
||||
const BASIN_ALPHA_MUTED = 0.06;
|
||||
|
||||
/** 화면 좌표 폴리곤 안에 점이 있는지(홀짝 규칙). 유역을 눌러 고를 때 쓴다. */
|
||||
function pointInRing(ring: ReadonlyArray<[number, number]>, x: number, y: number): boolean {
|
||||
let inside = false;
|
||||
for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) {
|
||||
const [xi, yi] = ring[index];
|
||||
const [xj, yj] = ring[previous];
|
||||
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
interface PipeMarker {
|
||||
chainage: number;
|
||||
source: PipeSource;
|
||||
@@ -289,10 +279,11 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl
|
||||
let smallest = Number.POSITIVE_INFINITY;
|
||||
basins.forEach((basin) => {
|
||||
if (basin.polygon_lonlat.length < 3) return;
|
||||
const ring = basin.polygon_lonlat.map(([lon, lat]) =>
|
||||
lonLatToScreen(normalizerRef as Normalizer, view, lon, lat),
|
||||
// 구멍 안(= 안에 든 다른 유역)을 누르면 바깥 유역이 잡히지 않는다.
|
||||
const rings = (basin.polygon_rings_lonlat ?? [basin.polygon_lonlat]).map((ring) =>
|
||||
ring.map(([lon, lat]) => lonLatToScreen(normalizerRef as Normalizer, view, lon, lat)),
|
||||
);
|
||||
if (!pointInRing(ring, x, y)) return;
|
||||
if (!pointInRings(rings, x, y)) return;
|
||||
if (basin.area_m2 < smallest) {
|
||||
smallest = basin.area_m2;
|
||||
hit = basin.index;
|
||||
@@ -508,12 +499,18 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl
|
||||
const ring = basin.polygon_lonlat.map(([lon, lat]) =>
|
||||
lonLatToScreen(normalizer, view, lon, lat),
|
||||
);
|
||||
// 조각·구멍을 한 경로에 담아 even-odd로 채운다 — 구멍이 실제로 뚫린다.
|
||||
const rings = (basin.polygon_rings_lonlat ?? [basin.polygon_lonlat]).map((part) =>
|
||||
part.map(([lon, lat]) => lonLatToScreen(normalizer, view, lon, lat)),
|
||||
);
|
||||
context.beginPath();
|
||||
ring.forEach(([px, py], order) => {
|
||||
if (order === 0) context.moveTo(px, py);
|
||||
else context.lineTo(px, py);
|
||||
rings.forEach((part) => {
|
||||
part.forEach(([px, py], order) => {
|
||||
if (order === 0) context.moveTo(px, py);
|
||||
else context.lineTo(px, py);
|
||||
});
|
||||
context.closePath();
|
||||
});
|
||||
context.closePath();
|
||||
// 하나를 고르면 나머지는 옅게 물러난다 — 고른 유역의 경계를 눈으로 좇을 수 있게.
|
||||
const muted = selectedBasin !== null && selectedBasin !== basin.index;
|
||||
const alpha = muted
|
||||
@@ -522,7 +519,7 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl
|
||||
? BASIN_ALPHA_SELECTED
|
||||
: BASIN_ALPHA_PLAIN;
|
||||
context.fillStyle = basinColor(index, alpha);
|
||||
context.fill();
|
||||
context.fill("evenodd");
|
||||
context.strokeStyle = basinColor(index, muted ? 0.3 : 0.95);
|
||||
context.lineWidth = selectedBasin === basin.index ? 2.8 : 1.8;
|
||||
context.stroke();
|
||||
|
||||
@@ -19,6 +19,11 @@ import {
|
||||
|
||||
export type FilledRing = {
|
||||
ring: ReadonlyArray<readonly [number, number]>;
|
||||
/**
|
||||
* 조각·구멍을 모두 편 링 목록. 주면 even-odd로 한 번에 채워 **구멍이 뚫린다** —
|
||||
* 아래 유역이 위 유역을 감싸는 도넛에서 위 유역을 덮지 않는다. 없으면 `ring` 하나만.
|
||||
*/
|
||||
rings?: ReadonlyArray<ReadonlyArray<readonly [number, number]>>;
|
||||
/** 면적 중심에 얹을 번호. 없으면 라벨을 그리지 않는다. */
|
||||
label?: string;
|
||||
};
|
||||
@@ -35,19 +40,27 @@ export function drawFilledRing(
|
||||
color: string,
|
||||
): void {
|
||||
if (entry.ring.length < 3) return;
|
||||
const rings = entry.rings?.length ? entry.rings : [entry.ring];
|
||||
let sumX = 0;
|
||||
let sumY = 0;
|
||||
context.beginPath();
|
||||
entry.ring.forEach(([lon, lat], index) => {
|
||||
rings.forEach((ring) => {
|
||||
if (ring.length < 3) return;
|
||||
ring.forEach(([lon, lat], index) => {
|
||||
const [x, y] = lonLatToScreen(normalizer, view, lon, lat);
|
||||
if (index === 0) context.moveTo(x, y);
|
||||
else context.lineTo(x, y);
|
||||
});
|
||||
context.closePath();
|
||||
});
|
||||
// 번호 자리는 바깥 링만 보고 잡는다 — 구멍까지 섞으면 중심이 유역 밖으로 밀린다.
|
||||
entry.ring.forEach(([lon, lat]) => {
|
||||
const [x, y] = lonLatToScreen(normalizer, view, lon, lat);
|
||||
sumX += x;
|
||||
sumY += y;
|
||||
if (index === 0) context.moveTo(x, y);
|
||||
else context.lineTo(x, y);
|
||||
});
|
||||
context.closePath();
|
||||
context.fillStyle = color;
|
||||
context.fill();
|
||||
context.fill("evenodd");
|
||||
context.strokeStyle = color;
|
||||
context.lineWidth = 1.6;
|
||||
context.stroke();
|
||||
@@ -87,6 +100,26 @@ export function drawRingBadge(
|
||||
}
|
||||
|
||||
/** 폴리곤 정점 평균의 화면 좌표 — 배지를 얹을 자리. */
|
||||
/**
|
||||
* 점이 조각·구멍으로 이루어진 유역 안에 있는가 — 링마다 홀짝을 뒤집는 even-odd 판정.
|
||||
* 구멍(안에 든 다른 유역) 안을 누르면 바깥 유역이 잡히지 않는다.
|
||||
*/
|
||||
export function pointInRings(
|
||||
rings: ReadonlyArray<ReadonlyArray<readonly [number, number]>>,
|
||||
x: number,
|
||||
y: number,
|
||||
): boolean {
|
||||
let inside = false;
|
||||
for (const ring of rings) {
|
||||
for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) {
|
||||
const [xi, yi] = ring[index];
|
||||
const [xj, yj] = ring[previous];
|
||||
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
|
||||
}
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
export function ringCenterOnScreen(
|
||||
ring: ReadonlyArray<readonly [number, number]>,
|
||||
normalizer: Normalizer,
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type ViewState,
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { pointInRing } from "./B05_Profile_UI_Drainage_Parts";
|
||||
import { pointInRings } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
|
||||
import type { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
|
||||
import type { createMapContextMenu } from "@ui/ui_template_context_menu";
|
||||
|
||||
@@ -123,10 +123,11 @@ export function bindDrainageInteractions(params: DrainageInteractParams): void {
|
||||
let smallest = Number.POSITIVE_INFINITY;
|
||||
params.getBasins().forEach((basin) => {
|
||||
if (basin.polygon_lonlat.length < 3) return;
|
||||
const ring = basin.polygon_lonlat.map(([lon, lat]) =>
|
||||
lonLatToScreen(normalizer, view, lon, lat),
|
||||
// 구멍(안에 든 다른 유역) 안을 누르면 바깥 유역이 잡히지 않도록 링 전체로 판정한다.
|
||||
const rings = (basin.polygon_rings_lonlat ?? [basin.polygon_lonlat]).map((ring) =>
|
||||
ring.map(([lon, lat]) => lonLatToScreen(normalizer, view, lon, lat)),
|
||||
);
|
||||
if (!pointInRing(ring, x, y)) return;
|
||||
if (!pointInRings(rings, x, y)) return;
|
||||
// 겹치면 면적이 작은 쪽을 고른다(안쪽 조각 우선).
|
||||
if (basin.area_m2 < smallest) {
|
||||
smallest = basin.area_m2;
|
||||
|
||||
@@ -88,7 +88,7 @@ export function drawDrainageScene(
|
||||
}
|
||||
drawFilledRing(
|
||||
context,
|
||||
{ ring: basin.polygon_lonlat },
|
||||
{ ring: basin.polygon_lonlat, rings: basin.polygon_rings_lonlat },
|
||||
normalizer,
|
||||
view,
|
||||
// 하나를 고르면 나머지는 옅게 물러난다.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user