Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1

This commit is contained in:
2026-10-08 13:55:30 +09:00
4 changed files with 284 additions and 1 deletions
@@ -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
@@ -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"]))
@@ -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():
+67
View File
@@ -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")}