"""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, _STRUCTURE_FONT_SIZE, _STRUCTURE_SIZE_MM, _TITLE_FONT_SIZE, CONTOUR_COLOR, MM, STREAM_COLOR, STRUCTURE_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), ("road_edge", "노견선", False), ("fill_outline", "성토 외곽선", False), ("cut_outline", "절토 외곽선", False), ("centerline", "중심선", False), ("ip", "IP", False), ("radius", "R", False), ("station", "측점 NO", False), ("endpoints", "시종점", False), ("refuge", "대피소 · 사토장", False), ("drainage", "배수시설 기호", 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"], }, { "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", "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, } 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 _ground(samples: list[dict[str, Any]]) -> list[tuple[float, float]]: return 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)) ) def _ground_at(ground: list[tuple[float, float]], offset: float) -> float: offsets = [x for x, _z in ground] 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) def side_is_cut(samples: list[dict[str, Any]], edge: float, elevation: float) -> bool: """노견 끝에서 지반이 노면보다 높으면 그 쪽 비탈은 절토다(낮으면 성토).""" ground = _ground(samples) return len(ground) >= 2 and _ground_at(ground, edge) > elevation def slope_end( design_line: list[dict[str, Any]], samples: list[dict[str, Any]], edge: float, side: str ) -> float: """노견 끝에서 바깥으로 — 설계선이 원지반에 닿는 오프셋(비탈 끝). 못 닿으면 설계선 끝. 설계선(B06 횡단 설계 결과)은 비탈이 지반과 만난 뒤로 지반을 따라간다 — 그 첫 자리가 끝이다. 좌측은 오프셋이 커지는 쪽, 우측은 작아지는 쪽이 바깥이다. """ ground = _ground(samples) 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 end, differs = edge, False for offset, elevation in outward: if abs(elevation - _ground_at(ground, offset)) <= 1e-3: return offset if differs else end differs, end = True, offset return end def _split_runs( points: list[list[float]], flags: list[bool] ) -> tuple[list[list[list[float]]], list[list[list[float]]]]: """한 선을 참 · 거짓 구간으로 끊는다 — 바뀌는 자리 점은 앞뒤 구간이 나눠 가져 이어진다.""" runs: dict[bool, list[list[list[float]]]] = {True: [], False: []} current: list[list[float]] = [] previous: bool | None = None for point, flag in zip(points, flags): if flag != previous: if current: current.append(point) current = [point] runs[flag].append(current) previous = flag else: current.append(point) return ( [run for run in runs[True] if len(run) >= 2], [run for run in runs[False] if len(run) >= 2], ) 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], dict[str, Any], dict[str, Any]]: """측점별 노견 끝 · 비탈 끝 오프셋에서 노견선 · 성토 외곽선 · 절토 외곽선 · 비탈 눈금을 만든다. `records` = [{chainage_m, left_edge_m, left_end_m, right_edge_m, right_end_m, left_cut?, right_cut?, (x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의 중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 노선을 따라 보간한다. 비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다. """ records = sorted( ( { **record, **{f"{s}_cut": float(bool(record.get(f"{s}_cut"))) for s in ("left", "right")}, } for record in records ), key=lambda record: record["chainage_m"], ) if len(route) < 2 or not records: return {"lines": []}, {"lines": []}, {"lines": []}, {"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} cut: dict[str, list[bool]] = {"left": [], "right": []} 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 cut: cut[side].append(_interp(records, chainage, f"{side}_cut") > 0.5) # 눈금은 노견 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아 선다(납품 도면 관례). 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]]) cut_lines: list[list[list[float]]] = [] fill_lines: list[list[list[float]]] = [] for side in cut: cut_runs, fill_runs = _split_runs(lines[f"{side}_end_m"], cut[side]) cut_lines += cut_runs fill_lines += fill_runs return ( {"lines": [lines["left_edge_m"], lines["right_edge_m"]]}, {"lines": fill_lines}, {"lines": cut_lines}, {"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", *_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") 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"], (cx + size + 1.0, cy), _STRUCTURE_FONT_SIZE, 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), ], }