"""B07 계획평면도 종류 — 정보 조각을 골라 한 장으로 얹는다(PLAN 60장). 계획평면도는 여러 종류(지형 · 노선배치도 · 대피소 · 라이다 · 사용자 추가)가 **같은 양식**이다. 정보 조각(등고선 · 중심선 · 노견선 · 성토 · 절토 외곽선 · 비탈 눈금 · IP · R · 측점 NO · 시종점 · 대피소/사토장 · 배수시설 기호 · 라이다 음영)마다 프로젝트 저장소에 파일이 하나씩 있고, 종류는 그중 무엇을 얹을지 고른 목록이다. 이 모듈은 조각 기하 계산과 도면 조립만 한다(파일 입출력 없음). 좌표 규약: 종이 mm = (사업지 좌표 m - 그 장 도곽 최소점) x (1000 / 종류 축척 분모) (기본 1/1,200). """ 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, ) 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 ( _COMPASS_MARGIN, _COMPASS_SIZE, _FONT_SIZE, _ROUTE_WIDTH, _STRUCTURE_FONT_SIZE, _STRUCTURE_SIZE_MM, _TITLE_FONT_SIZE, CONTOUR_COLOR, MM, STREAM_COLOR, STRUCTURE_COLOR, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanLabels import ( label_entities, place_labels, text_width, ) # 비탈 · 노선 따라가기 기하는 700줄을 넘어 떼어냄 — 호출부 import 경로는 그대로. from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanSlope import ( # noqa: F401 TICK_STEP_M, route_frame, side_is_cut, slope_end, slope_pieces, ) from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( compass_entities, entities_bbox, frame_entities, scale_fields, ) from config.config_system import DRAWING_SCALE_PLAN # 조각 (id, 이름, 도면층 잠금) — 그리는 차례(배경이 먼저). PIECES: tuple[tuple[str, str, bool], ...] = ( ("lidar", "라이다 음영", True), ("contour", "등고선", True), ("stream", "계류", True), ("slope_ticks", "비탈 눈금", False), ("road_edge", "노견선", False), ("fill_outline", "성토 외곽선", False), ("cut_outline", "절토 외곽선", False), ("centerline", "중심선", False), ("ip", "IP", False), ("radius", "R", False), ("ip_table", "IP 표", False), ("station", "측점 NO", False), ("endpoints", "시종점", False), ("refuge", "대피소 · 사토장", False), ("drainage", "배수시설 기호", False), # 용지도(93장) — 연속지적도 · 행정구역 ("parcel", "필지 경계", False), ("parcel_info", "필지 정보", False), ("emd", "읍면동 경계", False), ("sgg", "시군구 경계", False), ) PIECE_IDS: tuple[str, ...] = tuple(piece for piece, _name, _locked in PIECES) # 옛 조각 id → 나눈 조각들 — 옛 종류 파일을 읽을 때 펼친다. LEGACY_PIECES: dict[str, tuple[str, ...]] = { "slope_outline": ("road_edge", "fill_outline", "cut_outline"), } _OUTLINE = LEGACY_PIECES["slope_outline"] _TERRAIN = ("contour", "stream", "centerline", *_OUTLINE, "slope_ticks", "ip", "radius") # 기본 종류 — 지울 수 있다. id 는 옛 도면 id(plan_terrain …)와 같아 확정본이 이어진다. DEFAULT_KINDS: tuple[dict[str, Any], ...] = ( {"id": "terrain", "name": "지형", "pieces": [*_TERRAIN, "station"]}, { "id": "route", "name": "노선배치도", "pieces": [p for p in _TERRAIN if p != "radius"] + ["station", "endpoints", "ip_table"], }, { "id": "layout", "name": "대피소", "pieces": [p for p in _TERRAIN if p != "radius"] + ["station", "refuge"], }, # 라이다 = 지형과 같은 정보 · 배경만 라이다(등고선 · 계류 대신). { "id": "lidar", "name": "라이다", "pieces": ["lidar", *[p for p in _TERRAIN if p not in ("contour", "stream")], "station"], }, # 용지도 = 옛 따로 도면과 같은 정보(등고선 · 노선 · 지적 · 행정 경계). { "id": "landuse", "name": "용지도", "pieces": ["contour", "centerline", "parcel", "parcel_info", "emd", "sgg"], }, # 배수유역도 = 양식이 다른 종류(1/6,000 한 장 · 정보표) — 조각을 고르지 않는다(109장). {"id": "watershed", "name": "배수유역도", "pieces": [], "layout": "watershed"}, ) _COLORS = { "contour": CONTOUR_COLOR, "stream": STREAM_COLOR, "centerline": "#ff4d4d", "road_edge": "#4dd8e6", "fill_outline": "#4dd8e6", "cut_outline": "#4dd8e6", "slope_ticks": "#ff4d4d", "ip": "#4d7dff", "radius": "#4d7dff", "station": "#ffe066", "endpoints": "#ff9d4d", "refuge": "#7ddc7d", "drainage": STRUCTURE_COLOR, "parcel": "#8c6b4f", "parcel_info": "#a8703f", # 밝음 · 어두움 바탕 둘 다 읽히는 중간 밤색 "emd": "#2f7d4f", "sgg": "#a63d3d", } # 행정 · 지적 경계 (선굵기, 파선) _BOUNDARY_STYLE: dict[str, tuple[int, list[int] | None]] = { "parcel": (1, None), "emd": (2, [8, 4]), "sgg": (3, [14, 5, 3, 5]), } PARCEL_FONT = 3.6 # 필지 정보 글 크기(종이 mm) — 용지도 자리 잡기도 이 값 _MAJOR_EVERY = 5 # 측점 NO 큰 글자 · 동그라미 간격(측점 수) _LABEL_FONT = 2.5 _MAJOR_FONT = 4.5 _TICK_MM = 1.5 _CIRCLE_MM = 1.2 def layer_id(piece: str) -> str: return f"b07-plan-{piece}" def curve_pieces( curves: list[dict[str, Any]], route: list[tuple[float, float, float]], box: tuple[float, float, float, float], ) -> tuple[dict[str, Any], dict[str, Any]]: """곡선표에서 IP(교각점 · 접선)와 R 라벨을 만든다. 번호는 노선 전체 차례(IP1 …).""" ips: list[dict[str, Any]] = [] radii: list[dict[str, Any]] = [] min_x, min_y, max_x, max_y = box for number, curve in enumerate(curves, start=1): apex, start, end = curve.get("apex"), curve.get("start"), curve.get("end") radius = curve.get("radius_m") if not (apex and start and end) or not isinstance(radius, (int, float)): continue if not (min_x <= apex[0] <= max_x and min_y <= apex[1] <= max_y): continue ips.append( { "name": f"IP{number}", "apex": list(apex[:2]), "start": list(start[:2]), "end": list(end[:2]), } ) # R 라벨 후보는 곡선 한가운데에서 교각점 반대쪽(곡선 안쪽) · 바깥으로(종이 mm · 141-1). middle = min( ((x, y) for _c, x, y in route), key=lambda point: math.hypot(point[0] - apex[0], point[1] - apex[1]), default=(apex[0], apex[1]), ) dx, dy = middle[0] - apex[0], middle[1] - apex[1] length = math.hypot(dx, dy) or 1.0 radii.append( { "label": f"R={round(float(radius), 1):g}", "middle": list(middle), "dir": [dx / length, dy / length], } ) return {"items": ips}, {"items": radii} # ── 도면 조립 ───────────────────────────────────────── def _circle(center: tuple[float, float], radius: float) -> list[tuple[float, float]]: return [ (center[0] + radius * math.cos(a), center[1] + radius * math.sin(a)) for a in (2 * math.pi * i / 24 for i in range(25)) ] def _reading(nx: float, ny: float) -> tuple[float, float]: """글자 방향 — 노선 방향(법선을 돌린 것)으로 쓰되 거꾸로 서지 않게.""" tx, ty = ny, -nx return (tx, ty) if tx > 0 or (abs(tx) < 1e-9 and ty > 0) else (-tx, -ty) def _side_spots(cx: float, cy: float, size: float) -> list[dict[str, Any]]: """기호 곁 글 후보 — 오른쪽(옛 자리) → 왼 · 위 · 아래 → 멀리 오른 · 왼(지시선).""" return [ {"x": cx + size + 1.0, "y": cy, "align": "left"}, {"x": cx - size - 1.0, "y": cy, "align": "right"}, {"x": cx, "y": cy + size + 2.5}, {"x": cx, "y": cy - size - 2.5}, {"x": cx + size + 8.0, "y": cy + 4.0, "align": "left", "far": True}, {"x": cx - size - 8.0, "y": cy - 4.0, "align": "right", "far": True}, ] def _piece_entities( drawing_id: str, piece: str, data: dict[str, Any], paper: Any, mm: float = MM, labels: list[dict[str, Any]] | None = None, ) -> list[dict[str, Any]]: """조각의 선 · 기호 엔티티. 글은 후보 자리째 `labels` 에 모음(자리는 `place_labels` · 141-1).""" layer, color = layer_id(piece), _COLORS.get(piece, TABLE_LABEL_COLOR) out: list[dict[str, Any]] = [] labels = labels if labels is not None else [] def line(points: Any, suffix: str, width: int = 1, dash: list[int] | None = None) -> None: entity = polyline_entity( drawing_id, [paper(p) for p in points], layer, color, suffix=suffix, width=width, dash=dash, ) if entity: out.append(entity) def text( seed: str, label: str, size: float, spots: list[dict[str, Any]], anchor: tuple[float, float] | None = None, ) -> None: labels.append( { "piece": piece, "seed": f"{drawing_id}:{piece}:{seed}", "label": label, "size": size, "layer": layer, "color": color, "anchor": anchor, "candidates": spots, } ) if piece == "lidar": image, box = data.get("image"), data.get("box") if image and box: left, bottom = paper((box[0], box[1])) right, top = paper((box[2], box[3])) corners = [(left, bottom), (right, bottom), (right, top), (left, top)] out.append( { "id": f"{drawing_id}:lidar", "type": "Image", "lineColor": "#ffffff", "lineWidth": 1, "layerId": layer, "shapeData": { "points": [{"x": x, "y": y} for x, y in corners], "imageData": image, }, } ) elif piece in ("contour", "stream", *_OUTLINE): for index, points in enumerate(data.get("lines") or []): line(points, f":{index}") elif piece == "centerline": for index, points in enumerate(data.get("lines") or []): line(points, f":{index}", width=_ROUTE_WIDTH) elif piece == "slope_ticks": for index, points in enumerate(data.get("ticks") or []): line(points, f":{index}") elif piece == "ip": for index, item in enumerate(data.get("items") or []): line([item["start"], item["apex"], item["end"]], f":{index}", dash=[4, 2]) apex = paper(item["apex"]) out_circle = polyline_entity( drawing_id, _circle(apex, _CIRCLE_MM), layer, color, suffix=f":c:{index}" ) if out_circle: out.append(out_circle) ax, ay, reach = apex[0], apex[1], _CIRCLE_MM + 3.0 text( f"{index}", item["name"], _LABEL_FONT, [ {"x": ax, "y": ay - reach}, {"x": ax, "y": ay + reach}, {"x": ax - _CIRCLE_MM - 1.0, "y": ay, "align": "right"}, {"x": ax + _CIRCLE_MM + 1.0, "y": ay, "align": "left"}, {"x": ax, "y": ay - reach - 5.0, "far": True}, {"x": ax, "y": ay + reach + 5.0, "far": True}, ], apex, ) elif piece == "radius": for index, item in enumerate(data.get("items") or []): if "middle" not in item: # 옛 조각 파일 — 자리 하나 at = paper(item["at"]) text(f"{index}", item["label"], _LABEL_FONT, [{"x": at[0], "y": at[1]}]) continue mx, my = paper(item["middle"]) dx, dy = item["dir"] text( f"{index}", item["label"], _LABEL_FONT, [ {"x": mx + dx * d, "y": my + dy * d, "far": abs(d) > 6.0} for d in (3.0, 6.0, -3.0, -6.0, 10.0, -10.0) ], (mx, my), ) elif piece == "ip_table": out.extend(ip_table_entities(drawing_id, data)) elif piece == "station": for index, item in enumerate(data.get("items") or []): cx, cy = paper((item["x"], item["y"])) nx, ny = item["nx"], item["ny"] line( [ (item["x"] - nx * _TICK_MM / mm, item["y"] - ny * _TICK_MM / mm), (item["x"] + nx * _TICK_MM / mm, item["y"] + ny * _TICK_MM / mm), ], f":{index}", ) if item.get("major"): circle = polyline_entity( drawing_id, _circle((cx, cy), _CIRCLE_MM), layer, color, suffix=f":c:{index}" ) if circle: out.append(circle) gap, (tx, ty) = _MAJOR_FONT * 1.8, _reading(nx, ny) shift = text_width(item["label"], _MAJOR_FONT) / 2.0 + 2.0 text( f"{index}", item["label"], _MAJOR_FONT, [ { "x": cx + nx * g + tx * t, "y": cy + ny * g + ty * t, "direction": (tx, ty), } for g, t in ((gap, 0.0), (-gap, 0.0), (gap, shift), (gap, -shift)) ] + [ {"x": cx + nx * g, "y": cy + ny * g, "direction": (tx, ty), "far": True} for g in (gap * 1.8, -gap * 1.8) ], (cx, cy), ) elif piece == "endpoints": for index, item in enumerate(data.get("items") or []): nx, ny = item["nx"], item["ny"] reach = 6.0 / mm # 종이 6mm 막대 line( [ (item["x"] - nx * reach, item["y"] - ny * reach), (item["x"] + nx * reach, item["y"] + ny * reach), ], f":{index}", width=2, ) cx, cy = paper((item["x"], item["y"])) text( f"{index}", item["label"], _LABEL_FONT + 0.5, [ {"x": cx + nx * g, "y": cy + ny * g, "far": abs(g) > 9.0} for g in (9.0, -9.0, 15.0, -15.0) ], (cx, cy), ) elif piece == "refuge": size = 2.0 # 네모 기호 반크기(종이 mm) for index, item in enumerate(data.get("items") or []): x, y, half = item["x"], item["y"], size / mm corners = [(-1, -1), (1, -1), (1, 1), (-1, 1), (-1, -1)] line([(x + sx * half, y + sy * half) for sx, sy in corners], f":{index}") cx, cy = paper((x, y)) text(f"{index}", item["label"], _LABEL_FONT, _side_spots(cx, cy, size), (cx, cy)) elif piece == "drainage": size = _STRUCTURE_SIZE_MM # 마름모 기호 반크기(종이 mm) for index, item in enumerate(data.get("items") or []): cx, cy = paper((item["x"], item["y"])) corners = [(0, 1), (1, 0), (0, -1), (-1, 0), (0, 1)] diamond = polyline_entity( drawing_id, [(cx + sx * size, cy + sy * size) for sx, sy in corners], layer, color, suffix=f":{index}", ) if diamond: out.append(diamond) text( f"{index}", item["label"], _STRUCTURE_FONT_SIZE, _side_spots(cx, cy, size), (cx, cy) ) elif piece in _BOUNDARY_STYLE: width, dash = _BOUNDARY_STYLE[piece] for index, points in enumerate(data.get("lines") or []): line(points, f":{index}", width=width, dash=dash) elif piece == "parcel_info": for index, item in enumerate(data.get("items") or []): out.extend( _balloon_entities( f"{drawing_id}:{piece}", str(index), item["lines"], paper(item["anchor"]), paper(item["center"]), "rect", layer=layer, color=color, font=PARCEL_FONT, ) ) return out def _table_rect(entity: dict[str, Any]) -> tuple[float, float, float, float]: shape = entity["shapeData"] x, y = shape["origin"]["x"], shape["origin"]["y"] return (x, y - sum(shape["rowHeights"]), x + sum(shape["columnWidths"]), y) def build_plan_kind_drawing( drawing_id: str, label: str, box: tuple[float, float, float, float], pieces: dict[str, dict[str, Any]], scale: int = DRAWING_SCALE_PLAN, ) -> dict[str, Any]: """고른 조각만 얹어 계획평면도 한 장을 만든다. `box` = 그 장 도곽 범위(실좌표 m).""" min_x, min_y = box[0], box[1] mm = 1000.0 / scale def paper(point: Any) -> tuple[float, float]: return ((point[0] - min_x) * mm, (point[1] - min_y) * mm) entities: list[dict[str, Any]] = [] labels: list[dict[str, Any]] = [] obstacles: list[tuple[float, float, float, float]] = [] for piece in PIECE_IDS: if piece in pieces: made = _piece_entities(drawing_id, piece, pieces[piece], paper, mm, labels) entities.extend(made) # 글이 비켜 갈 자리 — IP 표 · 필지 상자(141-1). 제목은 내용 위에 따로 서서 안 겹침. if piece == "ip_table": obstacles += [_table_rect(e) for e in made if e.get("type") == "Table"] elif piece == "parcel_info": for item in pieces[piece].get("items") or []: width, height = balloon_size(item["lines"], PARCEL_FONT) cx, cy = paper(item["center"]) obstacles.append( (cx - width / 2, cy - height / 2, cx + width / 2, cy + height / 2) ) 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) entities.extend( compass_entities( drawing_id, ( frame_box[2] + _COMPASS_MARGIN + _COMPASS_SIZE / 2.0, frame_box[3] - _COMPASS_SIZE / 2.0, ), _COMPASS_SIZE, ) ) bbox = entities_bbox(entities) or frame_box 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, "b07-plan-title", _TITLE_FONT_SIZE, TABLE_LABEL_COLOR, ) ) entities.append( _text_entity( f"{drawing_id}:scale", f"S = 1/{scale:,}", max_bx, max_by + 5.0, "b07-plan-title", _FONT_SIZE, TABLE_LABEL_COLOR, align="right", ) ) entities.extend( frame_entities( drawing_id, entities_bbox(entities) or bbox, fit=False, fields={"도면명": label, **scale_fields(("", scale))}, ) ) layers = [ _layer(layer_id(piece), name, locked=locked) for piece, name, locked in PIECES if piece in pieces ] return { "format": DRAWING_FORMAT, "entities": entities, "layers": [ *layers, _layer("b07-plan-title", "표제"), _layer(FRAME_LAYER_ID, "도각", locked=True), ], }