"""B07 계획평면도 종류 — 정보 조각을 골라 한 장으로 얹는다(PLAN 60장). 계획평면도는 여러 종류(지형 · 노선배치도 · 대피소 · 라이다 · 사용자 추가)가 **같은 양식**이다. 정보 조각(등고선 · 중심선 · 비탈 외곽선 · 비탈 눈금 · IP · R · 측점 NO · 시종점 · 대피소/사토장 · 라이다 배경)마다 프로젝트 저장소에 파일이 하나씩 있고, 종류는 그중 무엇을 얹을지 고른 목록이다. 이 모듈은 조각 기하 계산과 도면 조립만 한다(파일 입출력 없음). 좌표 규약: 종이 mm = (사업지 좌표 m - 그 장 도곽 최소점) x MM (1/1,200). """ import math from bisect import bisect_left 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_Plan import ( _COMPASS_MARGIN, _COMPASS_SIZE, _FONT_SIZE, _ROUTE_WIDTH, _TITLE_FONT_SIZE, CONTOUR_COLOR, MM, STREAM_COLOR, ) 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), ("slope_outline", "성토사면 외곽선", False), ("centerline", "중심선", False), ("ip", "IP", False), ("radius", "R", False), ("station", "측점 NO", False), ("endpoints", "시종점", False), ("refuge", "대피소 · 사토장", False), ) PIECE_IDS: tuple[str, ...] = tuple(piece for piece, _name, _locked in PIECES) _TERRAIN = ("contour", "stream", "centerline", "slope_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"], }, { "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"], }, ) _COLORS = { "contour": CONTOUR_COLOR, "stream": STREAM_COLOR, "centerline": "#ff4d4d", "slope_outline": "#4dd8e6", "slope_ticks": "#ff4d4d", "ip": "#4d7dff", "radius": "#4d7dff", "station": "#ffe066", "endpoints": "#ff9d4d", "refuge": "#7ddc7d", } TICK_STEP_M = 2.0 # 비탈 눈금 간격(실거리 m) — 긴 · 짧은 눈금이 번갈아 선다 _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 route_frame( route: list[tuple[float, float, float]], chainage: float ) -> tuple[float, float, float, float]: """노선 (누가거리, x, y) 위 그 누가거리 자리와 좌측 법선 (x, y, nx, ny).""" chainages = [item[0] for item in route] index = min(max(bisect_left(chainages, chainage), 1), len(route) - 1) (c0, x0, y0), (c1, x1, y1) = route[index - 1], route[index] dx, dy = x1 - x0, y1 - y0 length = math.hypot(dx, dy) or 1.0 ratio = (chainage - c0) / (c1 - c0) if c1 > c0 else 0.0 return (x0 + dx * ratio, y0 + dy * ratio, -dy / length, dx / length) def _interp(records: list[dict[str, float]], chainage: float, key: str) -> float: chainages = [record["chainage_m"] for record in records] index = bisect_left(chainages, chainage) if index <= 0: return records[0][key] if index >= len(records): return records[-1][key] before, after = records[index - 1], records[index] span = after["chainage_m"] - before["chainage_m"] ratio = (chainage - before["chainage_m"]) / span if span > 0 else 0.0 return before[key] + (after[key] - before[key]) * ratio def slope_end( design_line: list[dict[str, Any]], samples: list[dict[str, Any]], edge: float, side: str ) -> float: """노견 끝에서 바깥으로 — 설계선이 원지반에 닿는 오프셋(비탈 끝). 못 닿으면 설계선 끝. 설계선(B06 횡단 설계 결과)은 비탈이 지반과 만난 뒤로 지반을 따라간다 — 그 첫 자리가 끝이다. 좌측은 오프셋이 커지는 쪽, 우측은 작아지는 쪽이 바깥이다. """ ground = sorted( (float(item["offset_m"]), float(item["elevation_m"])) for item in samples if item.get("valid", True) and isinstance(item.get("elevation_m"), (int, float)) ) points = sorted((float(p["offset_m"]), float(p["elevation_m"])) for p in design_line) outward = ( [p for p in points if p[0] > edge] if side == "left" else [p for p in points[::-1] if p[0] < edge] ) if not ground: return outward[-1][0] if outward else edge offsets = [offset for offset, _z in ground] def ground_at(offset: float) -> float: index = min(max(bisect_left(offsets, offset), 1), len(ground) - 1) (x0, z0), (x1, z1) = ground[index - 1], ground[index] return z0 + (z1 - z0) * ((offset - x0) / (x1 - x0) if x1 > x0 else 0.0) end, differs = edge, False for offset, elevation in outward: if abs(elevation - ground_at(offset)) <= 1e-3: return offset if differs else end differs, end = True, offset return end def slope_pieces( route: list[tuple[float, float, float]], records: list[dict[str, Any]], start_m: float, end_m: float, ) -> tuple[dict[str, Any], dict[str, Any]]: """측점별 노견 끝 · 비탈 끝 오프셋에서 외곽선 넷과 비탈 눈금을 만든다. `records` = [{chainage_m, left_edge_m, left_end_m, right_edge_m, right_end_m, (x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의 중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 노선을 따라 보간한다. """ records = sorted(records, key=lambda record: record["chainage_m"]) if len(route) < 2 or not records: return {"lines": [], "stations": []}, {"ticks": []} keys = ("left_edge_m", "left_end_m", "right_edge_m", "right_end_m") by_chainage = {round(record["chainage_m"], 3): record for record in records} samples = { round(record["chainage_m"], 3) for record in records if start_m <= record["chainage_m"] <= end_m } step = start_m while step <= end_m: samples.add(round(step, 3)) step += TICK_STEP_M lines: dict[str, list[list[float]]] = {key: [] for key in keys} ticks: list[list[list[float]]] = [] for index, chainage in enumerate(sorted(samples)): record = by_chainage.get(chainage) if record and all(k in record for k in ("x", "y", "nx", "ny")): x, y, nx, ny = record["x"], record["y"], record["nx"], record["ny"] else: x, y, nx, ny = route_frame(route, chainage) offsets = {key: _interp(records, chainage, key) for key in keys} points = { key: [round(x + nx * value, 4), round(y + ny * value, 4)] for key, value in offsets.items() } for key in keys: lines[key].append(points[key]) # 눈금은 노견 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아 선다(납품 도면 관례). for side in ("left", "right"): edge, end = offsets[f"{side}_edge_m"], offsets[f"{side}_end_m"] if abs(end - edge) < 0.05: continue reach = end if index % 2 == 0 else edge + (end - edge) / 2.0 ticks.append([points[f"{side}_edge_m"], [x + nx * reach, y + ny * reach]]) outline = { "lines": [lines[key] for key in keys], "stations": [ {key: record[key] for key in ("chainage_m", *keys)} for record in records if start_m <= record["chainage_m"] <= end_m ], } return outline, {"ticks": ticks} 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 라벨은 곡선 한가운데에서 교각점 반대쪽(곡선 안쪽)으로 조금 띄운다. 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}", "at": [middle[0] + dx / length * 4.0, middle[1] + dy / length * 4.0], } ) 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 _piece_entities( drawing_id: str, piece: str, data: dict[str, Any], paper: Any ) -> list[dict[str, Any]]: layer, color = layer_id(piece), _COLORS.get(piece, TABLE_LABEL_COLOR) out: list[dict[str, Any]] = [] 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, at: tuple[float, float], size: float, **kw: Any) -> None: out.append( _text_entity( f"{drawing_id}:{piece}:{seed}", label, at[0], at[1], layer, size, color, **kw ) ) 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", "slope_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) text(f"{index}", item["name"], (apex[0], apex[1] - _CIRCLE_MM - 3.0), _LABEL_FONT) elif piece == "radius": for index, item in enumerate(data.get("items") or []): text(f"{index}", item["label"], paper(item["at"]), _LABEL_FONT) 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 = _MAJOR_FONT * 1.8 text( f"{index}", item["label"], (cx + nx * gap, cy + ny * gap), _MAJOR_FONT, direction=_reading(nx, ny), ) 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"], (cx + nx * 9.0, cy + ny * 9.0), _LABEL_FONT + 0.5) 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"], (cx + size + 1.0, cy), _LABEL_FONT, align="left") return out def build_plan_kind_drawing( drawing_id: str, label: str, box: tuple[float, float, float, float], pieces: dict[str, dict[str, Any]], ) -> dict[str, Any]: """고른 조각만 얹어 계획평면도 한 장을 만든다. `box` = 그 장 도곽 범위(실좌표 m).""" min_x, min_y = box[0], box[1] def paper(point: Any) -> tuple[float, float]: return ((point[0] - min_x) * MM, (point[1] - min_y) * MM) entities: list[dict[str, Any]] = [] for piece in PIECE_IDS: if piece in pieces: entities.extend(_piece_entities(drawing_id, piece, pieces[piece], paper)) # 도곽 · 방위표 자리는 조각과 무관하게 같은 자리 — 그 장 범위를 기준으로 잡는다. 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/{DRAWING_SCALE_PLAN:,}", 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(("", DRAWING_SCALE_PLAN))}, ) ) 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), ], }