노선이 유역 해칭 아래에 깔려 보이지 않았다. polyline_entity에 width를 더해 노선만 굵게(3) 그리고, 그리기 순서를 해칭·정보표 다음으로 옮긴다. bbox 계산에는 그대로 포함시켜 오른쪽 칸 위치는 흔들리지 않는다. 오른쪽 칸을 방위표(맨 위) → 유역 정보표(아래로 쌓기) 순으로 바꾸고 방위표를 14mm에서 26mm로 키운다. 도면 오른쪽 위 구석에 따로 놓던 옛 배치는 걷어낸다. 등고선·세류선 절취가 경계 바깥 점을 하나씩 물고 나와 외곽이 들쭉날쭉했다. Liang-Barsky 선분 절취로 바꿔 끝점이 경계 위에 정확히 놓이게 하고, 그만큼 두었던 물림 여유(_CLIP_SLACK 14mm)를 없앤다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
420 lines
15 KiB
Python
420 lines
15 KiB
Python
"""B07 유역도(수리집수면적유역도) CAD 조립 — 배수 지점별 집수유역을 평면도로 옮긴다.
|
|
|
|
납품 양식(2026-08-30 사용자 제시 도면):
|
|
- 배경: 수치지형도의 등고선·세류선(도엽 GeoJSON).
|
|
- 노선: 계획 노선 중심선.
|
|
- 유역: 배수 지점마다 유역 경계를 다른 색으로, 안쪽에 번호.
|
|
- 구역별 정보표: 번호·No.측점·배수규격 / 유역면적(ha) / 유역표고(m) / 유하거리(m).
|
|
- 제목 「수리집수면적유역도」, 방위표, 척도(S=1/6,000).
|
|
|
|
값은 B04 배수유역 산출물(`detail_basins.geojson`의 피처 속성)을 그대로 읽는다 —
|
|
유역 계산·수리 계산을 여기서 다시 하지 않는다. 좌표는 라우터가 사업지 CRS(m)로
|
|
바꿔 넘기고, 이 모듈은 종이 mm로만 옮긴다.
|
|
|
|
좌표 규약: 종이 mm = (사업지 좌표 m - 콘텐츠 최소점) x MM (1/6,000 -> 1 m = 1/6 mm).
|
|
"""
|
|
|
|
import math
|
|
from typing import Any
|
|
from uuid import uuid5
|
|
|
|
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
|
_ENTITY_NS,
|
|
DRAWING_FORMAT,
|
|
FRAME_LAYER_ID,
|
|
TABLE_LABEL_COLOR,
|
|
TABLE_VALUE_COLOR,
|
|
_format,
|
|
_layer,
|
|
_text_entity,
|
|
polyline_entity,
|
|
station_no_label,
|
|
table_entity,
|
|
)
|
|
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
|
compass_entities,
|
|
entities_bbox,
|
|
frame_entities,
|
|
usable_area,
|
|
)
|
|
from config.config_system import DRAINAGE_RECOMMEND_DIAMETERS_MM, DRAWING_SCALE_BASIN
|
|
|
|
# 도면 좌표 = 종이 mm. 실거리 1 m가 종이에서 차지하는 mm (1/6,000 -> 0.1667).
|
|
MM = 1000.0 / DRAWING_SCALE_BASIN
|
|
|
|
CONTOUR_LAYER_ID = "b08-basin-contour"
|
|
CONTOUR_COLOR = "#6b7684"
|
|
STREAM_LAYER_ID = "b08-basin-stream"
|
|
STREAM_COLOR = "#4d9dff"
|
|
ROUTE_LAYER_ID = "b08-basin-route"
|
|
ROUTE_COLOR = "#ffe066"
|
|
BASIN_LAYER_ID = "b08-basin-area"
|
|
TABLE_LAYER_ID = "b08-basin-table"
|
|
TITLE_LAYER_ID = "b08-basin-title"
|
|
|
|
# 유역마다 돌려 쓰는 색 (첨부 도면처럼 이웃 유역이 서로 구분되게).
|
|
BASIN_COLORS = (
|
|
"#ff4d4d",
|
|
"#4dff88",
|
|
"#4d9dff",
|
|
"#ffe066",
|
|
"#ff66ff",
|
|
"#66ffe0",
|
|
"#ff9d4d",
|
|
"#b794f6",
|
|
)
|
|
|
|
# 유역 채칭 — 납품 도면은 유역마다 격자 해칭이 들어간다(2026-08-30 사용자 지적).
|
|
HATCH_STYLE = "cross"
|
|
HATCH_SPACING_MM = 2.5
|
|
HATCH_ANGLE_RAD = math.pi / 4
|
|
|
|
_FONT_SIZE = 2.2
|
|
# 유역 번호는 해칭 위에 얹히므로 흰 원판을 깔고 그 안에 적는다(2026-08-30 사용자 지시).
|
|
_NUMBER_FONT_SIZE = 4.5
|
|
_BADGE_RADIUS_MM = 3.6
|
|
_BADGE_FILL = "#ffffff"
|
|
_BADGE_TEXT_COLOR = "#111111"
|
|
_BADGE_SEGMENTS = 24
|
|
_TITLE_FONT_SIZE = 7.0
|
|
|
|
# 구역별 정보표 치수(mm).
|
|
_BOX_WIDTH = 46.0
|
|
_BOX_ROW_H = 4.5
|
|
_BOX_LABEL_W = 20.0
|
|
_BOX_GAP = 3.0
|
|
_BOX_MARGIN = 12.0 # 지형 콘텐츠 오른쪽 여백
|
|
_BOX_ROWS = (("유역면적", "area"), ("유역표고", "relief"), ("유하거리", "flow"))
|
|
_TITLE_BAND = 22.0 # 제목·척도가 차지하는 위쪽 띠(mm)
|
|
_CLIP_SLACK = 0.0 # 절취가 경계에서 정확히 끊기므로 여유가 필요 없다(2026-08-31)
|
|
|
|
# 오른쪽 칸: 방위표를 맨 위에 놓고 그 아래로 유역 정보표를 쌓는다(2026-08-31 사용자 지시).
|
|
_COMPASS_SIZE = 26.0 # 방위표 긴 변(mm)
|
|
_COMPASS_GAP = 6.0 # 방위표와 첫 정보표 사이(mm)
|
|
# 노선은 지형·유역 위에 얹혀야 보인다 — 굵기도 배경선보다 굵게 한다.
|
|
_ROUTE_WIDTH = 3
|
|
|
|
|
|
def map_area_mm() -> tuple[float, float]:
|
|
"""지형 배경이 차지할 수 있는 크기(mm) — A1 작도영역에서 정보표 칸과 제목 띠를 뺀다.
|
|
|
|
라우터는 이 크기를 축척으로 되돌려 등고선·세류선 절취 범위를 잡는다(정의처 한 곳).
|
|
"""
|
|
width, height = usable_area()
|
|
# _CLIP_SLACK: 절취가 범위 밖 점을 하나씩 물고 나오므로 그만큼 미리 뺀다.
|
|
return (
|
|
width - (_BOX_MARGIN + _BOX_WIDTH) - _CLIP_SLACK,
|
|
height - _TITLE_BAND - _CLIP_SLACK,
|
|
)
|
|
|
|
|
|
def _number(value: Any, fallback: float = 0.0) -> float:
|
|
return float(value) if isinstance(value, (int, float)) else fallback
|
|
|
|
|
|
def _centroid(ring: list[tuple[float, float]]) -> tuple[float, float]:
|
|
"""폴리곤 무게중심(단순 평균) — 번호를 놓을 자리."""
|
|
if not ring:
|
|
return (0.0, 0.0)
|
|
return (
|
|
sum(x for x, _y in ring) / len(ring),
|
|
sum(y for _x, y in ring) / len(ring),
|
|
)
|
|
|
|
|
|
def _diameter_label(props: dict[str, Any]) -> str:
|
|
"""배수규격 표기. 다리(세월교·물넘이) 판정이면 관경 대신 시설명을 적는다.
|
|
|
|
`pipe_diameter_mm`은 수리계산이 요구한 **소요 관경**(Φ929 같은 값)이라 도면에 그대로
|
|
쓸 수 없다. B04가 고른 규격관(`recommended_diameter_mm`)을 우선하고, 옛 저장본이라
|
|
그 값이 없으면 규격 목록(정의처 `config_system.DRAINAGE_RECOMMEND_DIAMETERS_MM`)에서
|
|
소요 관경 이상인 첫 규격으로 올린다.
|
|
"""
|
|
if props.get("bridge_required"):
|
|
return "물넘이포장"
|
|
recommended = props.get("recommended_diameter_mm")
|
|
if isinstance(recommended, (int, float)) and recommended > 0:
|
|
return f"Φ{int(round(float(recommended)))}mm"
|
|
diameter = props.get("pipe_diameter_mm")
|
|
if isinstance(diameter, (int, float)) and diameter > 0:
|
|
sizes = sorted(DRAINAGE_RECOMMEND_DIAMETERS_MM)
|
|
snapped = next((size for size in sizes if size >= float(diameter)), sizes[-1])
|
|
return f"Φ{int(snapped)}mm"
|
|
return "-"
|
|
|
|
|
|
def _hatch_entity(
|
|
drawing_id: str, number: int, ring: list[tuple[float, float]], color: str
|
|
) -> dict[str, Any]:
|
|
"""유역 안쪽 격자 해칭. openwebcad Hatch 엔티티라 CAD에서 무늬·간격을 그대로 편집한다."""
|
|
return {
|
|
"id": str(uuid5(_ENTITY_NS, f"{drawing_id}:basin:hatch:{number}")),
|
|
"type": "Hatch",
|
|
"lineColor": color,
|
|
"lineWidth": 1,
|
|
"layerId": BASIN_LAYER_ID,
|
|
"shapeData": {
|
|
"points": [{"x": x, "y": y} for x, y in ring],
|
|
"options": {
|
|
"style": HATCH_STYLE,
|
|
"color": color,
|
|
"spacing": HATCH_SPACING_MM,
|
|
"angle": HATCH_ANGLE_RAD,
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def _number_badge_entities(
|
|
drawing_id: str, number: int, center: tuple[float, float], color: str
|
|
) -> list[dict[str, Any]]:
|
|
"""유역 번호를 동그라미 배지로 그린다 — 흰 원판 + 유역색 테두리 + 검은 숫자.
|
|
|
|
해칭 위에 그냥 문자만 올리면 무늬에 묻혀 읽히지 않는다. 원판은 solid 해치라
|
|
화면(어두운 배경)과 인쇄(흰 배경) 어느 쪽에서도 숫자가 남는다.
|
|
"""
|
|
cx, cy = center
|
|
disc = [
|
|
(
|
|
cx + _BADGE_RADIUS_MM * math.cos(2 * math.pi * i / _BADGE_SEGMENTS),
|
|
cy + _BADGE_RADIUS_MM * math.sin(2 * math.pi * i / _BADGE_SEGMENTS),
|
|
)
|
|
for i in range(_BADGE_SEGMENTS)
|
|
]
|
|
return [
|
|
{
|
|
"id": str(uuid5(_ENTITY_NS, f"{drawing_id}:basin:badge:fill:{number}")),
|
|
"type": "Hatch",
|
|
"lineColor": _BADGE_FILL,
|
|
"lineWidth": 1,
|
|
"layerId": BASIN_LAYER_ID,
|
|
"shapeData": {
|
|
"points": [{"x": x, "y": y} for x, y in disc],
|
|
"options": {"style": "solid", "color": _BADGE_FILL, "spacing": 1, "angle": 0},
|
|
},
|
|
},
|
|
{
|
|
"id": str(uuid5(_ENTITY_NS, f"{drawing_id}:basin:badge:ring:{number}")),
|
|
"type": "Circle",
|
|
"lineColor": color,
|
|
"lineWidth": 1,
|
|
"layerId": BASIN_LAYER_ID,
|
|
"shapeData": {"center": {"x": cx, "y": cy}, "radius": _BADGE_RADIUS_MM},
|
|
},
|
|
_text_entity(
|
|
f"{drawing_id}:basin:number:{number}",
|
|
str(number),
|
|
cx,
|
|
cy,
|
|
BASIN_LAYER_ID,
|
|
_NUMBER_FONT_SIZE,
|
|
_BADGE_TEXT_COLOR,
|
|
),
|
|
]
|
|
|
|
|
|
def _info_box_entities(
|
|
drawing_id: str,
|
|
number: int,
|
|
props: dict[str, Any],
|
|
color: str,
|
|
origin: tuple[float, float],
|
|
interval_m: float,
|
|
) -> list[dict[str, Any]]:
|
|
"""구역 하나의 정보표(머리행 + 3행)를 표 객체 하나로 만든다.
|
|
|
|
선과 문자를 따로 두지 않는다 — 표로 두어야 나중에 DXF의 표로 나갈 수 있다.
|
|
"""
|
|
station = station_no_label(_number(props.get("chainage_m")), interval_m)
|
|
values = {
|
|
"area": f"{_number(props.get('area_m2')) / 10000.0:.2f} ha",
|
|
"relief": f"{_format(_number(props.get('relief_m')), 1)} m",
|
|
"flow": f"{_format(_number(props.get('flow_length_m')), 1)} m",
|
|
}
|
|
cells: list[list[dict[str, Any] | None]] = [
|
|
[
|
|
{"text": f"({number}) {station}", "color": TABLE_LABEL_COLOR},
|
|
{"text": f"배수규격 {_diameter_label(props)}", "color": TABLE_VALUE_COLOR},
|
|
]
|
|
]
|
|
for label, key in _BOX_ROWS:
|
|
cells.append(
|
|
[
|
|
{"text": label, "color": TABLE_LABEL_COLOR},
|
|
{"text": values[key], "color": TABLE_VALUE_COLOR},
|
|
]
|
|
)
|
|
return [
|
|
table_entity(
|
|
f"{drawing_id}:box:{number}",
|
|
origin,
|
|
[_BOX_LABEL_W, _BOX_WIDTH - _BOX_LABEL_W],
|
|
[_BOX_ROW_H] * (len(_BOX_ROWS) + 1),
|
|
cells,
|
|
TABLE_LAYER_ID,
|
|
color,
|
|
_FONT_SIZE,
|
|
TABLE_VALUE_COLOR,
|
|
)
|
|
]
|
|
|
|
|
|
def build_watershed_drawing(
|
|
drawing_id: str,
|
|
route_xy: list[tuple[float, float]],
|
|
basins: list[dict[str, Any]],
|
|
contours: list[list[tuple[float, float]]],
|
|
streams: list[list[tuple[float, float]]],
|
|
interval_m: float = 20.0,
|
|
) -> dict[str, Any]:
|
|
"""배수 유역·지형 배경·노선을 유역도 한 장으로 만든다.
|
|
|
|
좌표는 모두 사업지 CRS(m)다. `basins`는 {"ring": [(x, y)...], "props": {...}} 목록으로,
|
|
props는 B04 세부유역 피처 속성(chainage_m·area_m2·relief_m·flow_length_m·
|
|
pipe_diameter_mm·bridge_required)을 그대로 받는다.
|
|
"""
|
|
if not basins and not route_xy:
|
|
raise FileNotFoundError("배수 유역 산출물이 없습니다. B04 배수유역 분석을 먼저 하세요.")
|
|
|
|
# 콘텐츠 최소점을 원점으로 당겨 종이 mm로 옮긴다(사업지 좌표는 20만 m대라 그대로 쓰면
|
|
# 도면 좌표가 지나치게 커진다).
|
|
everything = [
|
|
*route_xy,
|
|
*(point for basin in basins for point in basin.get("ring") or []),
|
|
*(point for line in contours for point in line),
|
|
*(point for line in streams for point in line),
|
|
]
|
|
if not everything:
|
|
raise FileNotFoundError("유역도에 그릴 좌표가 없습니다.")
|
|
min_x = min(x for x, _y in everything)
|
|
min_y = min(y for _x, y in everything)
|
|
|
|
def paper(point: tuple[float, float]) -> tuple[float, float]:
|
|
return ((point[0] - min_x) * MM, (point[1] - min_y) * MM)
|
|
|
|
entities: list[dict[str, Any]] = []
|
|
for index, line in enumerate(contours):
|
|
contour = polyline_entity(
|
|
drawing_id,
|
|
[paper(point) for point in line],
|
|
CONTOUR_LAYER_ID,
|
|
CONTOUR_COLOR,
|
|
suffix=f":contour:{index}",
|
|
)
|
|
if contour:
|
|
entities.append(contour)
|
|
for index, line in enumerate(streams):
|
|
stream = polyline_entity(
|
|
drawing_id,
|
|
[paper(point) for point in line],
|
|
STREAM_LAYER_ID,
|
|
STREAM_COLOR,
|
|
suffix=f":stream:{index}",
|
|
)
|
|
if stream:
|
|
entities.append(stream)
|
|
# 노선은 지형·유역 위에 그려야 보인다 — 여기서 만들어 두고 맨 마지막에 얹는다.
|
|
route = polyline_entity(
|
|
drawing_id,
|
|
[paper(point) for point in route_xy],
|
|
ROUTE_LAYER_ID,
|
|
ROUTE_COLOR,
|
|
width=_ROUTE_WIDTH,
|
|
)
|
|
map_bbox = entities_bbox([*entities, route] if route else entities)
|
|
badges: list[dict[str, Any]] = []
|
|
for order, basin in enumerate(basins):
|
|
ring = [paper(point) for point in basin.get("ring") or []]
|
|
if len(ring) < 3:
|
|
continue
|
|
props = basin.get("props") or {}
|
|
number = int(props.get("index") or order + 1)
|
|
color = BASIN_COLORS[(number - 1) % len(BASIN_COLORS)]
|
|
entities.append(_hatch_entity(drawing_id, number, ring, color))
|
|
outline = polyline_entity(
|
|
drawing_id, [*ring, ring[0]], BASIN_LAYER_ID, color, suffix=f":basin:{number}"
|
|
)
|
|
if outline:
|
|
entities.append(outline)
|
|
# 번호 배지는 맨 마지막에 몰아 그린다 — 뒤에 오는 유역 해칭·정보표에 가리면 안 된다.
|
|
badges.extend(_number_badge_entities(drawing_id, number, _centroid(ring), color))
|
|
|
|
# 오른쪽 칸은 방위표가 맨 위, 그 아래로 유역 정보표를 쌓는다.
|
|
if map_bbox:
|
|
box_x = map_bbox[2] + _BOX_MARGIN
|
|
column_top = map_bbox[3]
|
|
else:
|
|
box_x, column_top = 0.0, 0.0
|
|
entities.extend(
|
|
compass_entities(
|
|
drawing_id,
|
|
(box_x + _BOX_WIDTH / 2.0, column_top - _COMPASS_SIZE / 2.0),
|
|
_COMPASS_SIZE,
|
|
)
|
|
)
|
|
box_y = column_top - _COMPASS_SIZE - _COMPASS_GAP
|
|
for order, basin in enumerate(basins):
|
|
props = basin.get("props") or {}
|
|
number = int(props.get("index") or order + 1)
|
|
color = BASIN_COLORS[(number - 1) % len(BASIN_COLORS)]
|
|
entities.extend(
|
|
_info_box_entities(
|
|
drawing_id,
|
|
number,
|
|
props,
|
|
color,
|
|
(box_x, box_y - order * ((len(_BOX_ROWS) + 1) * _BOX_ROW_H + _BOX_GAP)),
|
|
interval_m,
|
|
)
|
|
)
|
|
|
|
if route:
|
|
entities.append(route) # 지형·유역 위
|
|
entities.extend(badges) # 그리기 순서상 가장 위
|
|
|
|
bbox = entities_bbox(entities)
|
|
if bbox:
|
|
min_bx, _min_by, max_bx, max_by = bbox
|
|
entities.append(
|
|
_text_entity(
|
|
f"{drawing_id}:title",
|
|
"수 리 집 수 면 적 유 역 도",
|
|
(min_bx + max_bx) / 2.0,
|
|
max_by + 12.0,
|
|
TITLE_LAYER_ID,
|
|
_TITLE_FONT_SIZE,
|
|
TABLE_LABEL_COLOR,
|
|
)
|
|
)
|
|
entities.append(
|
|
_text_entity(
|
|
f"{drawing_id}:scale",
|
|
f"S = 1/{DRAWING_SCALE_BASIN:,}",
|
|
max_bx,
|
|
max_by + 5.0,
|
|
TITLE_LAYER_ID,
|
|
_FONT_SIZE,
|
|
TABLE_LABEL_COLOR,
|
|
align="right",
|
|
)
|
|
)
|
|
entities.extend(
|
|
frame_entities(
|
|
drawing_id, entities_bbox(entities) or bbox, fit=False, fields={"도면명": "유역도"}
|
|
)
|
|
)
|
|
|
|
return {
|
|
"format": DRAWING_FORMAT,
|
|
"entities": entities,
|
|
"layers": [
|
|
_layer(CONTOUR_LAYER_ID, "등고선", locked=True),
|
|
_layer(STREAM_LAYER_ID, "계류", locked=True),
|
|
_layer(ROUTE_LAYER_ID, "노선"),
|
|
_layer(BASIN_LAYER_ID, "배수유역"),
|
|
_layer(TABLE_LAYER_ID, "유역 제원"),
|
|
_layer(TITLE_LAYER_ID, "표제"),
|
|
_layer(FRAME_LAYER_ID, "도각", locked=True),
|
|
],
|
|
}
|