Files
Aislo/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py
T
eomsangdonandClaude Opus 5 a1dc0ee235 feat(B07): 토적도(유토곡선)·유역도(수리집수면적유역도)를 도면으로 낸다
납품 도면 2장을 B07 도면 목록에 새로 붙였다. 계산은 하지 않는다 —
유토곡선은 B06 확정 시 저장한 산출물(longitudinal_sections.data.mass_haul)을,
유역도는 B04 세부유역 GeoJSON과 도엽 등고선·세류선을 읽어 좌표만 종이 mm로 옮긴다.

- 도면 종류 확장: kind에 mass_haul·watershed 추가(Schema·Api_Fetch·openwebcad
  App.types), 12분류 라벨에 연결, 목록에 단장 도면 2건 상시 노출
- 엔진 신규: _Engine_Cad_MassHaul.py(축·곡선·평형선·띠 현·balloon·측점 테이블),
  _Engine_Cad_Basin.py(등고선·세류선 배경·노선·유역·구역별 정보표·방위표)
- 척도 상수: 유토곡선 H 1/2,000 · 세로 1mm=50㎥, 유역도 1/6,000 (A1 고정)
- 유토곡선 저장 payload에 띠·잔여의 기하 필드 추가 — 파이썬에 곡선 보간·토량 배분
  로직을 복제하지 않기 위해 값을 낳는 쪽(TS 엔진)에서 함께 남긴다
- 유역도 배경은 여러 도엽을 합쳐 받은 뒤 도곽 크기로 절취(clip_line_to_box)
- 배수규격은 소요 관경이 아니라 규격관(recommended_diameter_mm)으로 표기

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:01:39 +09:00

368 lines
12 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).
"""
from typing import Any
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
DRAWING_FORMAT,
FRAME_LAYER_ID,
TABLE_LABEL_COLOR,
TABLE_VALUE_COLOR,
_format,
_layer,
_line_entity,
_text_entity,
polyline_entity,
station_no_label,
)
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",
)
_FONT_SIZE = 2.2
_NUMBER_FONT_SIZE = 4.0
_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 = 14.0 # 절취 경계 물림 여유(mm)
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 _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행)."""
x0, y0 = origin # 박스 좌측 상단
entities: list[dict[str, Any]] = []
rows = len(_BOX_ROWS) + 1
bottom = y0 - rows * _BOX_ROW_H
for index in range(rows + 1):
y = y0 - index * _BOX_ROW_H
entities.append(
_line_entity(
f"{drawing_id}:box:{number}:h:{index}",
(x0, y),
(x0 + _BOX_WIDTH, y),
TABLE_LAYER_ID,
color,
)
)
for index, x in enumerate((x0, x0 + _BOX_LABEL_W, x0 + _BOX_WIDTH)):
entities.append(
_line_entity(
f"{drawing_id}:box:{number}:v:{index}",
(x, y0),
(x, bottom),
TABLE_LAYER_ID,
color,
)
)
head_y = y0 - _BOX_ROW_H / 2.0
station = station_no_label(_number(props.get("chainage_m")), interval_m)
entities.append(
_text_entity(
f"{drawing_id}:box:{number}:head:left",
f"({number}) {station}",
x0 + _BOX_LABEL_W / 2.0,
head_y,
TABLE_LAYER_ID,
_FONT_SIZE,
TABLE_LABEL_COLOR,
)
)
entities.append(
_text_entity(
f"{drawing_id}:box:{number}:head:right",
f"배수규격 {_diameter_label(props)}",
x0 + _BOX_LABEL_W + (_BOX_WIDTH - _BOX_LABEL_W) / 2.0,
head_y,
TABLE_LAYER_ID,
_FONT_SIZE,
TABLE_VALUE_COLOR,
)
)
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",
}
for index, (label, key) in enumerate(_BOX_ROWS):
center_y = y0 - (index + 1.5) * _BOX_ROW_H
entities.append(
_text_entity(
f"{drawing_id}:box:{number}:label:{key}",
label,
x0 + _BOX_LABEL_W / 2.0,
center_y,
TABLE_LAYER_ID,
_FONT_SIZE,
TABLE_LABEL_COLOR,
)
)
entities.append(
_text_entity(
f"{drawing_id}:box:{number}:value:{key}",
values[key],
x0 + _BOX_LABEL_W + (_BOX_WIDTH - _BOX_LABEL_W) / 2.0,
center_y,
TABLE_LAYER_ID,
_FONT_SIZE,
TABLE_VALUE_COLOR,
)
)
return entities
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
)
if route:
entities.append(route)
map_bbox = entities_bbox(entities)
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)]
outline = polyline_entity(
drawing_id, [*ring, ring[0]], BASIN_LAYER_ID, color, suffix=f":basin:{number}"
)
if outline:
entities.append(outline)
center = _centroid(ring)
entities.append(
_text_entity(
f"{drawing_id}:basin:number:{number}",
f"({number})",
center[0],
center[1],
BASIN_LAYER_ID,
_NUMBER_FONT_SIZE,
color,
)
)
# 정보표는 지형 콘텐츠 오른쪽에 위에서부터 쌓는다.
if map_bbox:
box_x = map_bbox[2] + _BOX_MARGIN
box_y = map_bbox[3]
else:
box_x, box_y = 0.0, 0.0
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,
)
)
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(compass_entities(drawing_id, (max_bx - 8.0, max_by - 8.0), 14.0))
entities.extend(frame_entities(drawing_id, entities_bbox(entities) or bbox, fit=False))
return {
"format": DRAWING_FORMAT,
"entities": entities,
"layers": [
_layer(CONTOUR_LAYER_ID, "Contours", locked=True),
_layer(STREAM_LAYER_ID, "Streams", locked=True),
_layer(ROUTE_LAYER_ID, "Route"),
_layer(BASIN_LAYER_ID, "Drainage Basins"),
_layer(TABLE_LAYER_ID, "Basin Info"),
_layer(TITLE_LAYER_ID, "Title"),
_layer(FRAME_LAYER_ID, "Frame", locked=True),
],
}