diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py index 3ce69d183..d1d53ed05 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py @@ -159,7 +159,11 @@ def _diameter_label(props: dict[str, Any]) -> str: def _hatch_entity( - drawing_id: str, number: int, ring: list[tuple[float, float]], color: str + drawing_id: str, + number: int, + ring: list[tuple[float, float]], + color: str, + layer: str = BASIN_LAYER_ID, ) -> dict[str, Any]: """유역 안쪽 격자 해칭. openwebcad Hatch 엔티티라 CAD에서 무늬·간격을 그대로 편집한다.""" return { @@ -167,7 +171,7 @@ def _hatch_entity( "type": "Hatch", "lineColor": color, "lineWidth": 1, - "layerId": BASIN_LAYER_ID, + "layerId": layer, "shapeData": { "points": [{"x": x, "y": y} for x, y in ring], "options": { @@ -181,7 +185,11 @@ def _hatch_entity( def _number_badge_entities( - drawing_id: str, number: int, center: tuple[float, float], color: str + drawing_id: str, + number: int, + center: tuple[float, float], + color: str, + layer: str = BASIN_LAYER_ID, ) -> list[dict[str, Any]]: """유역 번호를 동그라미 배지로 그린다 — 회색 원판 + 유역색 테두리 + 검은 숫자. @@ -202,7 +210,7 @@ def _number_badge_entities( "type": "Hatch", "lineColor": _BADGE_FILL, "lineWidth": 1, - "layerId": BASIN_LAYER_ID, + "layerId": layer, "shapeData": { "points": [{"x": x, "y": y} for x, y in disc], "options": {"style": "solid", "color": _BADGE_FILL, "spacing": 1, "angle": 0}, @@ -213,7 +221,7 @@ def _number_badge_entities( "type": "Circle", "lineColor": color, "lineWidth": 1, - "layerId": BASIN_LAYER_ID, + "layerId": layer, "shapeData": {"center": {"x": cx, "y": cy}, "radius": _BADGE_RADIUS_MM}, }, _text_entity( @@ -221,7 +229,7 @@ def _number_badge_entities( str(number), cx, cy, - BASIN_LAYER_ID, + layer, _NUMBER_FONT_SIZE, _BADGE_TEXT_COLOR, ), diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py index 3c628cfc3..2f2339c3a 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py @@ -20,6 +20,12 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( _text_entity, polyline_entity, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import ( + _BADGE_RADIUS_MM, + BASIN_COLORS, + _hatch_entity, + _number_badge_entities, +) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import ip_table_entities from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import _balloon_entities, balloon_size from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( @@ -62,6 +68,9 @@ PIECES: tuple[tuple[str, str, bool], ...] = ( ("lidar", "라이다 음영", True), ("contour", "등고선", True), ("stream", "계류", True), + # 배수유역(141-3) — 유역도 해치 · 번호 배지를 어느 평면도에나(장 범위로 자름) + ("basin", "배수유역 경계", False), + ("basin_no", "유역 번호", False), ("slope_ticks", "비탈 눈금", False), ("road_edge", "노견선", False), ("fill_outline", "성토 외곽선", False), @@ -446,6 +455,21 @@ def _piece_entities( font=PARCEL_FONT, ) ) + elif piece == "basin": + for index, item in enumerate(data.get("items") or []): + ring = [paper(point) for point in item["ring"]] + tint = BASIN_COLORS[(int(item["number"]) - 1) % len(BASIN_COLORS)] + seed = f"{drawing_id}:plan:{index}" + out.append(_hatch_entity(seed, int(item["number"]), ring, tint, layer)) + outline = polyline_entity(drawing_id, ring, layer, tint, suffix=f":{index}") + if outline: + out.append(outline) + elif piece == "basin_no": + for index, item in enumerate(data.get("items") or []): + number = int(item["number"]) + tint = BASIN_COLORS[(number - 1) % len(BASIN_COLORS)] + seed = f"{drawing_id}:plan:{index}" + out.extend(_number_badge_entities(seed, number, paper(item["center"]), tint, layer)) return out @@ -486,6 +510,11 @@ def build_plan_kind_drawing( obstacles.append( (cx - width / 2, cy - height / 2, cx + width / 2, cy + height / 2) ) + elif piece == "basin_no": # 유역 번호 배지(141-3) + radius = _BADGE_RADIUS_MM + for item in pieces[piece].get("items") or []: + cx, cy = paper(item["center"]) + obstacles.append((cx - radius, cy - radius, cx + radius, cy + radius)) entities.extend(label_entities(drawing_id, place_labels(labels, obstacles))) # 도곽 · 방위표 자리는 조각과 무관하게 같은 자리 — 그 장 범위를 기준으로 잡는다. frame_box = (0.0, 0.0, (box[2] - min_x) * mm, (box[3] - min_y) * mm) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py index 4419556fd..bbcea06eb 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py @@ -13,6 +13,8 @@ from typing import Any import numpy as np from pyproj import Transformer +from shapely.geometry import Polygon +from shapely.geometry import box as shapely_box from B04_PreProcess.B04_PreProcess_Router_Watershed import CONTOUR_FILE, STREAM_FILE from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import map_area_mm @@ -499,6 +501,30 @@ def watershed_source(context: Any) -> dict[str, Any]: 좌표계 기록 이전 저장본은 노선 CSV의 EPSG 라벨로 쓰였고, 그 라벨로 되돌려야 원래 미터 좌표가 나온다(2026-09-01). """ + route_xy = [(vertex.x, vertex.y) for vertex in context.vertices] + basins = metric_basins(context) + + # 배경(등고선·세류선)은 **여러 도엽을 합쳐 받은 뒤 도곽 크기로 절취**한다 + # (2026-08-30 사용자 지시 — 노선이 도엽 경계에 걸릴 수 있어 주변 도엽까지 받아 둔다). + # 읽기·환산·절취는 `map_background()` 한 곳에 있고 계획평면도·용지도도 같은 것을 쓴다. + background = map_background( + Path(context.project_root), + context.crs, + DRAWING_SCALE_BASIN, + map_area_mm(), + [*route_xy, *(point for basin in basins for point in basin["ring"])], + ) + + return { + "route_xy": route_xy, + "basins": basins, + "contours": background["contours"], + "streams": background["streams"], + } + + +def metric_basins(context: Any) -> list[dict[str, Any]]: + """세부유역 [{ring, props}] — 사업지 m(유역도 · 평면도 유역 조각 141-3 공용).""" basins_payload = _geojson_payload(detail_basins_path(context.stored_path)) to_basin_metric = Transformer.from_crs( "EPSG:4326", _basins_crs(context, basins_payload), always_xy=True @@ -533,21 +559,36 @@ def watershed_source(context: Any) -> dict[str, Any]: _BASIN_MAX_DISTANCE_M / 1000.0, dropped, ) + return basins - # 배경(등고선·세류선)은 **여러 도엽을 합쳐 받은 뒤 도곽 크기로 절취**한다 - # (2026-08-30 사용자 지시 — 노선이 도엽 경계에 걸릴 수 있어 주변 도엽까지 받아 둔다). - # 읽기·환산·절취는 `map_background()` 한 곳에 있고 계획평면도·용지도도 같은 것을 쓴다. - background = map_background( - Path(context.project_root), - context.crs, - DRAWING_SCALE_BASIN, - map_area_mm(), - [*route_xy, *(point for basin in basins for point in basin["ring"])], - ) - return { - "route_xy": route_xy, - "basins": basins, - "contours": background["contours"], - "streams": background["streams"], - } +def plan_basin_pieces( + context: Any, box: tuple[float, float, float, float] +) -> dict[str, dict[str, Any]]: + """평면도 조각 「배수유역 경계」 · 「유역 번호」 — 세부유역을 장 범위로 자름(141-3). + + 번호 = 유역도와 같은 번호(`index` · 없으면 차례) · 자리 = 잘린 조각 안 대표점. + 유역 저장본이 없으면 빈 조각(배수 분석 전). + """ + try: + basins = metric_basins(context) + except (FileNotFoundError, OSError, ValueError): + basins = [] + frame = shapely_box(*box) + rings: list[dict[str, Any]] = [] + numbers: list[dict[str, Any]] = [] + for order, basin in enumerate(basins): + number = int((basin.get("props") or {}).get("index") or order + 1) + try: + part = Polygon(basin["ring"]).buffer(0).intersection(frame) + except (ValueError, TypeError): + continue + if part.is_empty or part.area <= 0: + continue + for polygon in getattr(part, "geoms", [part]): + if isinstance(polygon, Polygon) and polygon.area > 0: + ring = [[round(x, 4), round(y, 4)] for x, y in polygon.exterior.coords] + rings.append({"number": number, "ring": ring}) + point = part.representative_point() + numbers.append({"number": number, "center": [round(point.x, 4), round(point.y, 4)]}) + return {"basin": {"items": rings}, "basin_no": {"items": numbers}} diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py index ef3be073d..cb6dc7534 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py @@ -49,6 +49,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( _plan_structures, _sheet_box, map_background, + plan_basin_pieces, plan_stations, watershed_source, ) @@ -523,6 +524,8 @@ def build_pieces( and min_y <= item["y"] <= max_y ] } + if {"basin", "basin_no"} & set(wanted): + pieces.update(plan_basin_pieces(context, box)) # 141-3 if "lidar" in wanted: try: image, image_box = _hillshade_for_box(context, box) diff --git a/resources/tester/test_141_3_b07_plan_basin.py b/resources/tester/test_141_3_b07_plan_basin.py new file mode 100644 index 000000000..205b9b400 --- /dev/null +++ b/resources/tester/test_141_3_b07_plan_basin.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +"""141-3 평면도 조각 「배수유역 경계」 · 「유역 번호」 — 세부유역을 장 범위로 잘라 유역도 해치 · 배지로. + +견본: 유역 1 = 장 안 정사각 · 유역 2 = 장 경계에 걸침(잘림) · 유역 3 = 장 밖(빠짐). +""" + +from __future__ import annotations + +from types import SimpleNamespace + +from B07_DesignDetail import B07_DesignDetail_Router_Support_Basin as basin +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import ( + PIECE_IDS, + build_plan_kind_drawing, + layer_id, +) +from B07_DesignDetail.B07_DesignDetail_Router_PlanKinds import PIECES + +BOX = (0.0, 0.0, 100.0, 100.0) +BASINS = [ + {"ring": [(10, 10), (40, 10), (40, 40), (10, 40), (10, 10)], "props": {"index": 1}}, + {"ring": [(80, 50), (120, 50), (120, 90), (80, 90), (80, 50)], "props": {"index": 2}}, + {"ring": [(300, 300), (320, 300), (320, 320), (300, 300)], "props": {"index": 3}}, +] + + +def _pieces(monkeypatch) -> dict: + monkeypatch.setattr(basin, "metric_basins", lambda _context: BASINS) + return basin.plan_basin_pieces(SimpleNamespace(), BOX) + + +def test_장_범위로_자르고_번호는_유역도와_같다(monkeypatch): + pieces = _pieces(monkeypatch) + rings = pieces["basin"]["items"] + assert [item["number"] for item in rings] == [1, 2] # 장 밖 3 은 빠짐 + clipped = rings[1]["ring"] + assert max(x for x, _y in clipped) == 100.0 # 장 경계에서 잘림 + numbers = {item["number"]: item["center"] for item in pieces["basin_no"]["items"]} + assert set(numbers) == {1, 2} + x, y = numbers[2] + assert 80 <= x <= 100 and 50 <= y <= 90 # 잘린 조각 안 + + +def test_유역_저장본_없으면_빈_조각(monkeypatch): + def missing(_context): + raise FileNotFoundError + + monkeypatch.setattr(basin, "metric_basins", missing) + assert basin.plan_basin_pieces(SimpleNamespace(), BOX) == { + "basin": {"items": []}, + "basin_no": {"items": []}, + } + + +def test_어느_평면도에나_조각_체크(monkeypatch): + assert {"basin", "basin_no"} <= set(PIECE_IDS) + assert ("basin", "배수유역 경계", False) in PIECES and ( + "basin_no", + "유역 번호", + False, + ) in PIECES + drawing = build_plan_kind_drawing("plan_x", "평면도(x)", BOX, _pieces(monkeypatch), 1200) + hatches = [e for e in drawing["entities"] if e["layerId"] == layer_id("basin")] + assert [e["type"] for e in hatches].count("Hatch") == 2 + badges = [e for e in drawing["entities"] if e["layerId"] == layer_id("basin_no")] + assert sorted(e["shapeData"]["label"] for e in badges if e["type"] == "Text") == ["1", "2"] + assert len({e["id"] for e in drawing["entities"]}) == len(drawing["entities"]) + names = {layer["id"]: layer["name"] for layer in drawing["layers"]} + assert names[layer_id("basin")] == "배수유역 경계"