"""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), ], }