From 1c1226eb4c1becddf9f881d99a56609dc14bf26b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 13:45:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(B07):=20=ED=8F=89=EB=A9=B4=EB=8F=84=20IP?= =?UTF-8?q?=20=ED=91=9C=20=EC=A1=B0=EA=B0=81=20=E2=80=94=20BP=20=C2=B7=20I?= =?UTF-8?q?P=20=C2=B7=20EP=20=EC=9E=90=EB=A3=8C=20=C2=B7=20=EC=9E=A5=20?= =?UTF-8?q?=EB=8F=84=EA=B3=BD=20=EC=99=BC=EC=AA=BD=20=EC=95=84=EB=9E=98=20?= =?UTF-8?q?=ED=91=9C=20=C2=B7=20=ED=91=9C=20=EB=B0=91=20=EC=84=A0=20?= =?UTF-8?q?=EC=9E=90=EB=A6=84(94-1=20=C2=B7=2094-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 Engine_Cad_IpTable — IA 곡선 접선 재계산 · TL · CL · SL · 도-분-초 · X 북 · Y 동 · 장마다 그 장 IP - PlanKinds 조각 ip_table 한 줄 · 그리기 갈래 · 노선배치도 기본 체크 - Router_Support_Plan build_pieces 한 갈래 - 시험 — 예시 검산 · 장 경계 · 잘림 · DXF Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- .../B07_DesignDetail_Engine_Cad_IpTable.py | 207 ++++++++++++++++++ .../B07_DesignDetail_Engine_Cad_PlanKinds.py | 6 +- .../B07_DesignDetail_Router_Support_Plan.py | 5 + resources/tester/test_b07_plan_kinds.py | 67 ++++++ 4 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 B07_DesignDetail/B07_DesignDetail_Engine_Cad_IpTable.py diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_IpTable.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_IpTable.py new file mode 100644 index 000000000..8fea242bf --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_IpTable.py @@ -0,0 +1,207 @@ +"""B07 평면도 IP 표 — 곡선표에서 BP · IP · EP 자료를 셈해 장 도곽 왼쪽 아래 표로(PLAN 94장). + +IP 는 곡선이 있는 것만 · 번호는 평면도 「IPn」 과 같은 곡선표 차례다. IA 는 저장 내각(소수 2자리) +대신 곡선 자기 접선(시점→교점 · 교점→종점 방향각)으로 다시 셈하고, TL · CL · SL 은 R 과 그 IA 로 +셈한다. 좌표는 측량 관례대로 X = 북(y) · Y = 동(x). + +표 자리(종이 mm)는 자료를 만들 때 정한다 — 표 밑 등고선 · 노선을 같은 자리로 잘라 내야 해서다. +""" + +import math +from typing import Any + +import numpy as np +from shapely.geometry import LineString, MultiLineString +from shapely.geometry import box as rect +from shapely.ops import unary_union +from shapely.prepared import prep + +from B05_Profile.B05_Profile_Engine_Sections_Core import ( + DESIGN_CURVE_MATCH_TOLERANCE_M, + _project_chainage, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import table_entity +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import _FONT_SIZE, MM + +LAYER_ID = "b07-plan-ip_table" +LINE_COLOR = "#ff4d4d" # 예시 표 — 선 빨강 · 글 파랑 +TEXT_COLOR = "#4d7dff" +_COLUMNS = (7.0, 6.0, 23.0) # [머리 | X·Y | 값] 종이 mm +_ROW_H = 5.0 +_MARGIN = 5.0 # 도곽 왼쪽 아래 모서리에서 띄움 +_LINE_GAP = 3.0 # 한 줄 넘쳐 위로 올린 줄 사이 +_IP_ROWS = 8 # IP · CO(X · Y) · IA · R · TL · CL · SL +_END_ROWS = 3 # BP · EP = IP · CO 만 +_VALUE_ROWS = (("I A", "ia"), ("R", "r"), ("T L", "tl"), ("C L", "cl"), ("S L", "sl")) +_CLIPPED = ("contour", "stream", "road_edge", "fill_outline", "cut_outline", "centerline") + + +def intersection_angle(start: Any, apex: Any, end: Any) -> float: + """교각 IA(도) — 시점→교점 · 교점→종점 방향각 차.""" + first = math.atan2(apex[1] - start[1], apex[0] - start[0]) + second = math.atan2(end[1] - apex[1], end[0] - apex[0]) + return abs(math.degrees((second - first + math.pi) % (2 * math.pi) - math.pi)) + + +def curve_values(radius: float, ia_deg: float) -> dict[str, float]: + """TL = R·tan(IA/2) · CL = R·IA(rad) · SL = R·(sec(IA/2) − 1).""" + half = math.radians(ia_deg) / 2.0 + return { + "tl": radius * math.tan(half), + "cl": radius * 2.0 * half, + "sl": radius * (1.0 / math.cos(half) - 1.0), + } + + +def dms(degrees: float) -> str: + """도-분-초 「94-59-58.38」 — 분 · 초 두 자리 · 초는 0.01″ 로 반올림해 60.00 이면 올림.""" + hundredths = round(degrees * 360000) + whole, rest = divmod(hundredths, 360000) + minutes, seconds = divmod(rest, 6000) + return f"{whole}-{minutes:02d}-{seconds // 100:02d}.{seconds % 100:02d}" + + +def ip_blocks( + curves: list[dict[str, Any]], + route: list[tuple[float, float, float]], + number: int, + total: int, + start_m: float, + end_m: float, +) -> list[dict[str, Any]]: + """그 장의 표 묶음 [BP?, IP…, EP?] — 곡선 가운데 누가거리가 그 장 [시작, 끝) 안인 것만. + + 장 경계 측점은 두 장이 나눠 가지므로 끝은 열어 둔다(마지막 장만 닫음) — 한 IP 는 한 장에만. + 노선에서 5 m 넘게 떨어진 옛 곡선은 버린다(B05 곡선 확폭과 같은 허용). + """ + if len(route) < 2: + return [] + points = np.asarray([(x, y) for _c, x, y in route], dtype=np.float64) + chainage = np.asarray([c for c, _x, _y in route], dtype=np.float64) + blocks: list[dict[str, Any]] = [] + if number == 1: + blocks.append({"name": "BP", "x": route[0][1], "y": route[0][2]}) + for index, curve in enumerate(curves, start=1): + start, apex, end = curve.get("start"), curve.get("apex"), curve.get("end") + radius = curve.get("radius_m") + if not (start and apex and end) or not isinstance(radius, (int, float)): + continue + start_gap, start_c = _project_chainage(points, chainage, tuple(start[:2])) + end_gap, end_c = _project_chainage(points, chainage, tuple(end[:2])) + if max(start_gap, end_gap) > DESIGN_CURVE_MATCH_TOLERANCE_M: + continue + middle = (start_c + end_c) / 2.0 + if (number > 1 and middle < start_m) or (number < total and middle >= end_m): + continue + ia = intersection_angle(start, apex, end) + blocks.append( + { + "name": str(index), + "x": float(apex[0]), + "y": float(apex[1]), + "ia": ia, + "r": float(radius), + **curve_values(float(radius), ia), + } + ) + if number == total: + blocks.append({"name": "EP", "x": route[-1][1], "y": route[-1][2]}) + return blocks + + +def _place(blocks: list[dict[str, Any]], width_mm: float) -> None: + """묶음마다 표 왼쪽 위 자리(종이 mm) `origin` — 왼쪽 아래부터 나란히 · 넘치면 위로 다음 줄.""" + block_w = sum(_COLUMNS) + per_line = max(1, int((width_mm - 2 * _MARGIN) // block_w)) + for order, block in enumerate(blocks): + line, column = divmod(order, per_line) + top = _MARGIN + line * (_IP_ROWS * _ROW_H + _LINE_GAP) + _IP_ROWS * _ROW_H + block["origin"] = [_MARGIN + column * block_w, top] + block["rows"] = _END_ROWS if "ia" not in block else _IP_ROWS + + +def _clip(pieces: dict[str, dict[str, Any]], blocks: list[dict[str, Any]], box: Any) -> None: + """표 칸 밑 선 조각(등고선 · 계류 · 노견선 · 외곽선 · 중심선)을 표 밖으로 잘라 낸다.""" + min_x, min_y = box[0], box[1] + width = sum(_COLUMNS) / MM + area = unary_union( + [ + rect( + min_x + b["origin"][0] / MM, + min_y + b["origin"][1] / MM - b["rows"] * _ROW_H / MM, + min_x + b["origin"][0] / MM + width, + min_y + b["origin"][1] / MM, + ) + for b in blocks + ] + ) + inside = prep(area) + for piece in _CLIPPED: + lines = (pieces.get(piece) or {}).get("lines") + if not lines: + continue + kept: list[list[list[float]]] = [] + for points in lines: + if len(points) < 2 or not inside.intersects(LineString(points)): + kept.append(points) + continue + rest = LineString(points).difference(area) + parts = rest.geoms if isinstance(rest, MultiLineString) else [rest] + kept += [ + [[round(x, 4), round(y, 4)] for x, y in part.coords] + for part in parts + if isinstance(part, LineString) and len(part.coords) >= 2 + ] + pieces[piece]["lines"] = kept + + +def ip_table_piece( + pieces: dict[str, dict[str, Any]], + curves: list[dict[str, Any]], + route: list[tuple[float, float, float]], + box: tuple[float, float, float, float], + chunk: dict[str, Any], + total: int, +) -> dict[str, Any]: + """그 장 IP 표 조각 {blocks} — 자리를 정하고 같은 자리 밑 선 조각을 `pieces` 에서 잘라 낸다.""" + blocks = ip_blocks( + curves, + route, + int(chunk["number"]), + total, + float(chunk["start_m"]), + float(chunk["end_m"]), + ) + _place(blocks, (box[2] - box[0]) * MM) + if blocks: + _clip(pieces, blocks, box) + return {"blocks": blocks} + + +def ip_table_entities(drawing_id: str, data: dict[str, Any]) -> list[dict[str, Any]]: + """묶음마다 표 하나 — 행 IP · CO(X · Y 2행 병합) · IA · R · TL · CL · SL(값 2 · 3열 병합).""" + out = [] + for block in data.get("blocks") or []: + cells: list[list[dict[str, Any] | None]] = [ + [{"text": "I P"}, {"text": block["name"], "colSpan": 2}, None], + [{"text": "C O", "rowSpan": 2}, {"text": "X"}, {"text": f"{block['y']:.5f}"}], + [None, {"text": "Y"}, {"text": f"{block['x']:.5f}"}], + ] + if "ia" in block: + for label, key in _VALUE_ROWS: + value = dms(block[key]) if key == "ia" else f"{block[key]:.5f}" + cells.append([{"text": label}, {"text": value, "colSpan": 2}, None]) + out.append( + table_entity( + f"{drawing_id}:ip_table:{block['name']}", + tuple(block["origin"]), + list(_COLUMNS), + [_ROW_H] * len(cells), + cells, + LAYER_ID, + LINE_COLOR, + _FONT_SIZE, + TEXT_COLOR, + ) + ) + return out diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py index cd51607fb..664a009e1 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py @@ -21,6 +21,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( _text_entity, polyline_entity, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import ip_table_entities from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( _COMPASS_MARGIN, _COMPASS_SIZE, @@ -54,6 +55,7 @@ PIECES: tuple[tuple[str, str, bool], ...] = ( ("centerline", "중심선", False), ("ip", "IP", False), ("radius", "R", False), + ("ip_table", "IP 표", False), ("station", "측점 NO", False), ("endpoints", "시종점", False), ("refuge", "대피소 · 사토장", False), @@ -73,7 +75,7 @@ DEFAULT_KINDS: tuple[dict[str, Any], ...] = ( { "id": "route", "name": "노선배치도", - "pieces": [p for p in _TERRAIN if p != "radius"] + ["station", "endpoints"], + "pieces": [p for p in _TERRAIN if p != "radius"] + ["station", "endpoints", "ip_table"], }, { "id": "layout", @@ -411,6 +413,8 @@ def _piece_entities( elif piece == "radius": for index, item in enumerate(data.get("items") or []): text(f"{index}", item["label"], paper(item["at"]), _LABEL_FONT) + 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"])) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py index 1274d78e1..7453933c7 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py @@ -24,6 +24,7 @@ from B05_Profile.B05_Profile_Engine_Sections import ( from B05_Profile.B05_Profile_Structures_Repository import load_structures from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args from B07_DesignDetail.B07_DesignDetail_Engine_Cad import infer_station_interval, station_no_label +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import ip_table_piece from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( _structure_label, plan_area_mm, @@ -411,6 +412,10 @@ def build_pieces( pieces["lidar"] = {"image": image, "box": list(image_box)} except (FileNotFoundError, ValueError, OSError): pieces["lidar"] = {"image": None, "box": None} + if "ip_table" in wanted: + pieces["ip_table"] = ip_table_piece( + pieces, _load_design_curves(project_root), route, box, chunk, total + ) folder = _design_root(project_root) / "plan_pieces" / str(number) for piece, data in pieces.items(): diff --git a/resources/tester/test_b07_plan_kinds.py b/resources/tester/test_b07_plan_kinds.py index a3cc127a6..ec00730f5 100644 --- a/resources/tester/test_b07_plan_kinds.py +++ b/resources/tester/test_b07_plan_kinds.py @@ -6,21 +6,30 @@ ④ 고른 조각만 도면 층으로 얹힘 ⑤ 조각 잘게(67장) — 노견선 · 성토 외곽선 · 절토 외곽선 따로 · 기본 넷 처음 체크 = 옛 그림 · 옛 종류 파일의 slope_outline 펼침 · 종류 업데이트(기본 넷 포함) · 확정본 풀림 · 배수시설 기호 +⑥ IP 표(94장) — 예시 검산 · 도-분-초 올림 · 장마다 그 장 IP 한 번 · 표 밑 선 자름 · 층 """ import json import math from pathlib import Path +import ezdxf import pytest from B06_Section.B06_Section_Engine_Design import compute_cross_design +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import ( + curve_values, + dms, + ip_blocks, + ip_table_piece, +) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import ( DEFAULT_KINDS, build_plan_kind_drawing, layer_id, slope_pieces, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Export import drawing_to_dxf from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _read_manifest, _write_manifest from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( _kinds_dir, @@ -230,3 +239,61 @@ def test_drainage_symbol_piece() -> None: mine = [e for e in drawing["entities"] if e.get("layerId") == layer_id("drainage")] assert {e["type"] for e in mine} >= {"Text"} and len(mine) >= 2 assert any(e["type"] == "Text" and e["shapeData"]["label"] == "D800" for e in mine) + + +def test_ip_table_example_values_and_dms() -> None: + ia = 94 + 59 / 60 + 58.38 / 3600 # 예시 그림 IP1 + values = curve_values(15.0, ia) + assert dms(ia) == "94-59-58.38" + assert [f"{values[k]:.5f}" for k in ("tl", "cl", "sl")] == ["16.36950", "24.87082", "7.20271"] + assert dms(1 + 59 / 60 + 59.9999 / 3600) == "2-00-00.00" # 초 60.00 올림 + assert dms(5 + 3 / 60 + 7.2 / 3600) == "5-03-07.20" + + +# ㄱ자 노선 — (0,0)→(100,0)→(100,100) · 모서리 R 15 곡선(가운데 누가거리 100) · 앞에 노선 밖 옛 곡선 +_ROUTE = [(0.0, 0.0, 0.0), (85.0, 85.0, 0.0), (115.0, 100.0, 15.0), (200.0, 100.0, 100.0)] +_CURVES = [ + {"apex": [500.0, 500.0], "start": [490.0, 500.0], "end": [500.0, 510.0], "radius_m": 10.0}, + {"apex": [100.0, 0.0], "start": [85.0, 0.0], "end": [100.0, 15.0], "radius_m": 15.0}, +] + + +def test_ip_blocks_one_sheet_each_and_ends() -> None: + first = ip_blocks(_CURVES, _ROUTE, 1, 2, 0.0, 100.0) + second = ip_blocks(_CURVES, _ROUTE, 2, 2, 100.0, 200.0) + assert [b["name"] for b in first] == ["BP"] # 경계 IP 는 뒤 장에만 + assert [b["name"] for b in second] == ["2", "EP"] # 번호 = 곡선표 차례(옛 곡선은 빠짐) + ip = second[0] + assert dms(ip["ia"]) == "90-00-00.00" and ip["tl"] == pytest.approx(15.0) + assert [b["name"] for b in ip_blocks(_CURVES, _ROUTE, 1, 1, 0.0, 200.0)] == ["BP", "2", "EP"] + + +def test_ip_table_drawing_clips_lines_under_table(tmp_path: Path) -> None: + box = (-20.0, -20.0, 300.0, 200.0) + contour = [[-20.0, 0.0], [300.0, 0.0]] # 표(왼쪽 아래 · 높이 48 m) 밑을 지나는 등고선 + pieces = { + "contour": {"lines": [contour]}, + "centerline": {"lines": [[[0.0, 100.0], [9.0, 100.0]]]}, + } + pieces["ip_table"] = ip_table_piece( + pieces, _CURVES, _ROUTE, box, {"number": 1, "start_m": 0.0, "end_m": 200.0}, 1 + ) + # IP 표 칸(x 29.2 ~ 72.4 · y -14 ~ 34)만 빠짐 · BP · EP 칸(3행)은 y 0 위라 안 자름 + assert pieces["contour"]["lines"] == [[[-20.0, 0.0], [29.2, 0.0]], [[72.4, 0.0], [300.0, 0.0]]] + assert pieces["centerline"]["lines"] == [[[0.0, 100.0], [9.0, 100.0]]] # 표 밖은 그대로 + drawing = build_plan_kind_drawing("plan_ip", "시험", box, pieces) + mine = [e for e in drawing["entities"] if e.get("layerId") == layer_id("ip_table")] + assert [e["type"] for e in mine] == ["Table"] * 3 + bp, ip, _ep = (e["shapeData"] for e in mine) + assert len(bp["rowHeights"]) == 3 and len(ip["rowHeights"]) == 8 + assert bp["cells"][1][2]["text"] == "0.00000" and ip["cells"][1][2]["text"] == "0.00000" + assert ip["cells"][2][2]["text"] == "100.00000" # Y = 동(x) + assert ip["cells"][3][1]["text"] == "90-00-00.00" + assert {layer["id"] for layer in drawing["layers"]} >= {layer_id("ip_table")} + assert "ip_table" in next(k for k in DEFAULT_KINDS if k["id"] == "route")["pieces"] + # DXF — 표 → 선 + 글(IP 묶음: 바깥 4 · 세로 10 · 가로 20 · 글 17) + raw, skipped = drawing_to_dxf({"entities": [mine[1]], "layers": drawing["layers"]}) + (tmp_path / "ip.dxf").write_bytes(raw) + space = ezdxf.readfile(tmp_path / "ip.dxf").modelspace() + assert skipped == {} and len(space.query("LINE")) == 34 and len(space.query("TEXT")) == 17 + assert {"90-00-00.00", "15.00000"} <= {t.dxf.text for t in space.query("TEXT")}