feat(B07): 평면도 조각 배수유역 경계 · 유역 번호 — 장 범위로 자름(141-3)
- 조각 둘 더함 — 「배수유역 경계」(유역도 해치 + 테두리) · 「유역 번호」(유역도 배지) · 어느 평면도 종류에나 체크로 - 세부유역 읽기를 metric_basins 로 떼어 유역도 · 평면도 공용 · 장 범위로 잘라 번호는 유역도와 같게 - 번호 배지는 글 자리 잡기의 장애물 · 새 test_141_3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")] == "배수유역 경계"
|
||||
Reference in New Issue
Block a user