"""B07 계획평면도 CAD 조립 — 수치등고선 배경 위에 노선·측점·구조물을 얹는다. 세 장이 같은 배경·같은 축척을 쓰고 주제만 다르다(2026-09-04 사용자 지시). - 계획평면도(지형) : 등고선·세류선만 - 계획평면도(노선배치도): 배경 + 계획노선 + 측점 - 계획평면도(배치도) : 배경 + 계획노선 + 구조물 배치 배경 자료는 유역도와 **같은 창구**(`B07_DesignDetail_Router_Support_Basin.map_background`) 에서 온다 — 도엽 GeoJSON 읽기·좌표 환산은 한 번뿐이고 여러 도면이 그 결과를 나눠 쓴다. 축척은 지식DB 「설계제원_총괄」 측량·도면 기준 **1/1,200 고정**이다. 횡단면도와 같은 원칙으로, 한 장에 안 들어가면 축척을 줄이지 않고 **장을 나눈다**. 좌표 규약: 종이 mm = (사업지 좌표 m - 그 장 콘텐츠 최소점) x MM (1/1,200 -> 1 m = 5/6 mm). """ import math from typing import Any from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( DRAWING_FORMAT, FRAME_LAYER_ID, TABLE_LABEL_COLOR, _layer, _text_entity, polyline_entity, station_plus_label, ) from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( compass_entities, entities_bbox, frame_entities, scale_fields, usable_area, ) from config.config_system import DRAWING_SCALE_PLAN # 도면 좌표 = 종이 mm. 실거리 1 m가 종이에서 차지하는 mm (1/1,200 -> 0.8333). MM = 1000.0 / DRAWING_SCALE_PLAN CONTOUR_LAYER_ID = "b07-plan-contour" CONTOUR_COLOR = "#6b7684" STREAM_LAYER_ID = "b07-plan-stream" STREAM_COLOR = "#4d9dff" ROUTE_LAYER_ID = "b07-plan-route" ROUTE_COLOR = "#ffe066" STATION_LAYER_ID = "b07-plan-station" STATION_COLOR = "#ff9d4d" STRUCTURE_LAYER_ID = "b07-plan-structure" STRUCTURE_COLOR = "#ff4d4d" TITLE_LAYER_ID = "b07-plan-title" _ROUTE_WIDTH = 3 _TITLE_FONT_SIZE = 7.0 _FONT_SIZE = 2.2 _STATION_FONT_SIZE = 2.0 _STATION_TICK_MM = 2.5 # 측점 눈금 반길이(종이 mm) _STRUCTURE_SIZE_MM = 3.0 # 구조물 기호 반크기(종이 mm) _STRUCTURE_FONT_SIZE = 2.2 _TITLE_BAND = 22.0 # 제목·척도가 차지하는 위쪽 띠(mm) _COMPASS_SIZE = 26.0 _COMPASS_MARGIN = 12.0 # 세 장의 주제 (id 접두어, 도면명, 노선·측점·구조물을 그리는지). PLAN_KINDS: tuple[tuple[str, str, bool, bool, bool], ...] = ( ("plan_terrain", "계획평면도(지형)", False, False, False), ("plan_route", "계획평면도(노선배치도)", True, True, False), ("plan_layout", "계획평면도(배치도)", True, False, True), ) PLAN_KIND_LABELS: dict[str, str] = {kind: label for kind, label, *_rest in PLAN_KINDS} # 구조물 종류별 표기 — pipe_points.json 의 facility 값 기준. _FACILITY_LABELS: dict[str, str] = { "ford_bridge": "세월교", "box_culvert": "BOX암거", "bridge": "교량", } def plan_area_mm() -> tuple[float, float]: """지형 배경이 차지할 수 있는 크기(mm) — A1 작도영역에서 방위표 칸과 제목 띠를 뺀다. 라우터는 이 크기를 축척으로 되돌려 등고선·세류선 절취 범위를 잡는다(정의처 한 곳). """ width, height = usable_area() return (width - (_COMPASS_MARGIN + _COMPASS_SIZE), height - _TITLE_BAND) def _chunk_span_m() -> tuple[float, float]: """한 장이 담을 수 있는 실거리(m) — 도곽 지형 영역을 축척으로 되돌린 크기.""" area_w, area_h = plan_area_mm() return (area_w * DRAWING_SCALE_PLAN / 1000.0, area_h * DRAWING_SCALE_PLAN / 1000.0) def plan_chunks(stations: list[tuple[float, float, float]]) -> list[dict[str, Any]]: """노선을 한 장에 들어가는 구간으로 나눈다. 각 항목: {number, start_m, end_m}. 입력은 종단 측점의 (누가거리 m, x, y)다 — **도면 목록과 도면 생성이 같은 자료**를 보아야 장수가 어긋나지 않는다(종단도 분할과 같은 방식). 축척 1/1,200 은 고정이므로 한 장에 안 들어가면 **노선을 따라 장을 나눈다** (2026-09-04 — 횡단면도와 같은 원칙). 경계 측점 1개를 중복시켜 장 사이에서 노선이 끊겨 보이지 않게 한다(납품 도면 관례). """ ordered = sorted(stations, key=lambda item: item[0]) if len(ordered) < 2: span = (ordered[0][0] if ordered else 0.0, ordered[0][0] if ordered else 0.0) return [{"number": 1, "start_m": span[0], "end_m": span[1]}] span_w, span_h = _chunk_span_m() def fits(part: list[tuple[float, float, float]]) -> bool: width = max(x for _c, x, _y in part) - min(x for _c, x, _y in part) height = max(y for _c, _x, y in part) - min(y for _c, _x, y in part) # 가로로 길든 세로로 길든 도곽에만 들어가면 된다 — 두 방향 다 본다. return (width <= span_w and height <= span_h) or (width <= span_h and height <= span_w) chunks: list[dict[str, Any]] = [] start = 0 while start < len(ordered) - 1: end = start + 1 while end + 1 < len(ordered) and fits(ordered[start : end + 2]): end += 1 chunks.append( { "number": len(chunks) + 1, "start_m": float(ordered[start][0]), "end_m": float(ordered[end][0]), } ) start = end # 경계 측점 1개 중복 return chunks def plan_drawing_id(kind: str, chunk: dict[str, Any], total: int) -> str: """장이 하나면 접두어 그대로, 여럿이면 `plan_route_2` 처럼 번호를 붙인다.""" return kind if total <= 1 else f"{kind}_{chunk['number']}" def plan_drawing_label(kind: str, chunk: dict[str, Any], total: int) -> str: label = PLAN_KIND_LABELS.get(kind, kind) return label if total <= 1 else f"{label} {chunk['number']}장" def _structure_label(structure: dict[str, Any]) -> str: """구조물 표기 — 세월교·BOX암거는 이름, 배수관은 관경(mm).""" facility = structure.get("facility") if isinstance(facility, str) and facility in _FACILITY_LABELS: return _FACILITY_LABELS[facility] options = structure.get("options") diameter = options.get("pipe_diameter_mm") if isinstance(options, dict) else None return f"D{int(diameter)}" if isinstance(diameter, (int, float)) else "배수시설" def _station_entities( drawing_id: str, stations: list[tuple[float, float, float]], interval_m: float, paper: Any, ) -> list[dict[str, Any]]: """측점 눈금과 이름(No.n+00)을 노선 위에 직각으로 세운다. 입력은 **종단 측점**(누가거리 m, x, y)이다 — 노선 정점은 수백 개라 전부 찍으면 뭉개지고, 정점의 누가거리는 측점 간격의 배수가 아니라 걸러지지도 않는다 (2026-09-04 실측: 눈금이 2개만 찍혔음). """ entities: list[dict[str, Any]] = [] for index, (chainage, x, y) in enumerate(stations): point = (x, y) before = stations[max(index - 1, 0)] after = stations[min(index + 1, len(stations) - 1)] dx, dy = after[1] - before[1], after[2] - before[2] length = math.hypot(dx, dy) or 1.0 # 노선 진행 방향의 법선 — 눈금을 노선과 직각으로 세운다. nx, ny = -dy / length, dx / length cx, cy = paper(point) tick = polyline_entity( drawing_id, [ (cx - nx * _STATION_TICK_MM, cy - ny * _STATION_TICK_MM), (cx + nx * _STATION_TICK_MM, cy + ny * _STATION_TICK_MM), ], STATION_LAYER_ID, STATION_COLOR, suffix=f":tick:{index}", ) if tick: entities.append(tick) entities.append( _text_entity( f"{drawing_id}:station:{index}", station_plus_label(chainage, interval_m), cx + nx * (_STATION_TICK_MM + 1.5), cy + ny * (_STATION_TICK_MM + 1.5), STATION_LAYER_ID, _STATION_FONT_SIZE, STATION_COLOR, ) ) return entities def _structure_entities( drawing_id: str, structures: list[dict[str, Any]], paper: Any, box: tuple[float, float, float, float], ) -> list[dict[str, Any]]: """구조물 위치를 마름모 기호 + 이름으로 찍는다. 이 장의 범위 밖은 건너뛴다.""" entities: list[dict[str, Any]] = [] min_x, min_y, max_x, max_y = box for index, structure in enumerate(structures): x, y = structure.get("x"), structure.get("y") if not isinstance(x, (int, float)) or not isinstance(y, (int, float)): continue if not (min_x <= x <= max_x and min_y <= y <= max_y): continue cx, cy = paper((float(x), float(y))) size = _STRUCTURE_SIZE_MM marker = polyline_entity( drawing_id, [ (cx, cy + size), (cx + size, cy), (cx, cy - size), (cx - size, cy), (cx, cy + size), ], STRUCTURE_LAYER_ID, STRUCTURE_COLOR, suffix=f":structure:{index}", ) if marker: entities.append(marker) entities.append( _text_entity( f"{drawing_id}:structure:label:{index}", _structure_label(structure), cx + size + 1.0, cy, STRUCTURE_LAYER_ID, _STRUCTURE_FONT_SIZE, STRUCTURE_COLOR, ) ) return entities def build_plan_drawing( kind: str, drawing_id: str, label: str, route_xy: list[tuple[float, float]], stations: list[tuple[float, float, float]], contours: list[list[tuple[float, float]]], streams: list[list[tuple[float, float]]], structures: list[dict[str, Any]], interval_m: float = 20.0, ) -> dict[str, Any]: """계획평면도 한 장을 만든다. 좌표는 모두 사업지 CRS(m)로 받아 종이 mm로만 옮긴다. `kind` 가 세 장 중 무엇을 그릴지 정한다(`PLAN_KINDS`). 배경은 세 장이 같다. """ with_route, with_station, with_structure = next( ((r, s, t) for name, _label, r, s, t in PLAN_KINDS if name == kind), (True, False, False), ) # 도곽 배치는 세 장이 같아야 한다 — 노선을 그리지 않는 지형도도 노선을 범위에 넣는다. everything = [ *route_xy, *(point for line in contours for point in line), *(point for line in streams for point in line), ] if not everything: raise FileNotFoundError( "계획평면도에 그릴 좌표가 없습니다. B04 전처리에서 수치지형도 도엽을 먼저 받으세요." ) min_x = min(x for x, _y in everything) min_y = min(y for _x, y in everything) max_x = max(x for x, _y in everything) max_y = max(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) map_bbox = entities_bbox(entities) # 노선·측점·구조물은 배경 위에 얹는다 — 아래에 깔리면 등고선에 묻힌다. if with_route: route = polyline_entity( drawing_id, [paper(point) for point in route_xy], ROUTE_LAYER_ID, ROUTE_COLOR, width=_ROUTE_WIDTH, ) if route: entities.append(route) if with_station and stations: entities.extend(_station_entities(drawing_id, stations, interval_m, paper)) if with_structure: entities.extend( _structure_entities(drawing_id, structures, paper, (min_x, min_y, max_x, max_y)) ) # 방위표는 지형 오른쪽 칸 맨 위에 둔다(유역도와 같은 자리). if map_bbox: entities.extend( compass_entities( drawing_id, ( map_bbox[2] + _COMPASS_MARGIN + _COMPASS_SIZE / 2.0, map_bbox[3] - _COMPASS_SIZE / 2.0, ), _COMPASS_SIZE, ) ) bbox = entities_bbox(entities) if bbox: min_bx, _min_by, max_bx, max_by = bbox entities.append( _text_entity( f"{drawing_id}:title", label, (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_PLAN:,}", 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={"도면명": label, **scale_fields(("", DRAWING_SCALE_PLAN))}, ) ) return { "format": DRAWING_FORMAT, "entities": entities, "layers": [ _layer(CONTOUR_LAYER_ID, "등고선", locked=True), _layer(STREAM_LAYER_ID, "계류", locked=True), _layer(ROUTE_LAYER_ID, "계획노선"), _layer(STATION_LAYER_ID, "측점"), _layer(STRUCTURE_LAYER_ID, "구조물"), _layer(TITLE_LAYER_ID, "표제"), _layer(FRAME_LAYER_ID, "도각", locked=True), ], }