diff --git a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts index 47e7a6d9..df279155 100644 --- a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts +++ b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts @@ -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>; area_m2: number; relief_m: number; flow_length_m: number; diff --git a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Flow.py b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Flow.py index a03b7521..216cfaf6 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Flow.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Flow.py @@ -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: diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index cb696cc1..86edeea1 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -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): diff --git a/B04_PreProcess/B04_PreProcess_UI_Basins.ts b/B04_PreProcess/B04_PreProcess_UI_Basins.ts index 716806e6..251369cf 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Basins.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Basins.ts @@ -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(); diff --git a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts index 4b9867a1..3fa47f41 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts @@ -19,6 +19,11 @@ import { export type FilledRing = { ring: ReadonlyArray; + /** + * 조각·구멍을 모두 편 링 목록. 주면 even-odd로 한 번에 채워 **구멍이 뚫린다** — + * 아래 유역이 위 유역을 감싸는 도넛에서 위 유역을 덮지 않는다. 없으면 `ring` 하나만. + */ + rings?: ReadonlyArray>; /** 면적 중심에 얹을 번호. 없으면 라벨을 그리지 않는다. */ 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>, + 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, normalizer: Normalizer, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Interact.ts b/B05_Profile/B05_Profile_UI_Drainage_Interact.ts index 9d49e317..6212e2d1 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Interact.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Interact.ts @@ -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; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Render.ts b/B05_Profile/B05_Profile_UI_Drainage_Render.ts index 9df429ae..015898e2 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Render.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Render.ts @@ -88,7 +88,7 @@ export function drawDrainageScene( } drawFilledRing( context, - { ring: basin.polygon_lonlat }, + { ring: basin.polygon_lonlat, rings: basin.polygon_rings_lonlat }, normalizer, view, // 하나를 고르면 나머지는 옅게 물러난다. diff --git a/common_util/common_util_drainage_detail.py b/common_util/common_util_drainage_detail.py index 21b90fbf..592b756f 100644 --- a/common_util/common_util_drainage_detail.py +++ b/common_util/common_util_drainage_detail.py @@ -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,