Files
Aislo/resources/tester/test_b07_plan_kinds.py
T
eomsangdonandClaude Sonnet 5.5 a96a60fb86 feat(524-1): B07 평면도 설정 모달 정리 — 기본 항목 지형도 하나 · 양식 칸 숨김 · 종류 척도 2열 · 항목 그룹 구분선 · 대피소 사토장 자리 지움
- 기본 종류 = 지형도 하나(옛 기본 이관 더하기 지움) · 대피소 조각 · 자리 폼 · plan-marks 끝점 지움
- 모달 양식 칸 = 평면도면 숨김 · 종류 척도 영역 2열 · 항목 3열 유지 + 그룹 구분선(PIECE_GROUPS 한 곳)
- 낡은 시험 수선: 기본 종류가 하나로 줄어 route · layout · landuse · watershed 기본 전제를 add_kind 로 세움 · 대피소 자리 시험 지움

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 14:23:59 +09:00

408 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""B07 계획평면도 종류 · 정보 조각 (PLAN 60장).
① 측점 둘에서 성토사면 외곽선 폭 = 횡단 설계 값(노견 끝 · 설계선이 지반에 닿는 자리)
② 종류 더하기 · 지우기(기본 종류 포함) · 다시 읽어 같음 · 기본 = 지형도 하나
③ (없앰 — 대피소 · 사토장 자리는 524-1 에서 지움)
④ 고른 조각만 도면 층으로 얹힘
⑤ 조각 잘게(67장) — 노견선 · 성토 외곽선 · 절토 외곽선 따로 · 기본 처음 체크 = 옛 그림 ·
옛 종류 파일의 slope_outline 펼침 · 종류 업데이트(기본 포함) · 확정본 풀림 · 배수시설 기호
⑥ IP 표(94장) — 예시 검산 · 도-분-초 올림 · 장마다 그 장 IP 한 번 · 표 밑 선 자름 · 층
⑦ 용지도 조각(93장) — 용지 조각 층
⑧ 배수유역도 종류(109장) — 새 종류로 더함 · 양식 watershed · 한 장 목록 · 지우기 · 다시 만들기 · IP 표 두 줄 차례 ·
용지도 상자 안 선 0
⑨ 종류 차례(140-2) — 끌어 놓은 차례 그대로 · 다시 읽어 같음 · 앞선 종류가 바뀐 종류만 확정 풂 · 목록 어긋나면 거절
"""
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 (
_place,
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,
add_kind,
delete_kind,
list_kinds,
plan_kind_items,
reorder_kinds,
slope_records,
update_kind,
)
def _samples(slope: float) -> list[dict[str, float]]:
"""좌(+)가 높은 비탈 지반 — 오프셋 -20 ~ 20 m, 0.5 m 간격."""
return [
{"offset_m": i * 0.5, "elevation_m": 100.0 + slope * i * 0.5, "valid": True}
for i in range(-40, 41)
]
def _project(tmp_path: Path) -> tuple[Path, dict, dict]:
"""+y 로 곧게 가는 노선 · 측점 0 · 20 · 40 · 횡단 파일 · B06 저장 설계."""
root = tmp_path / "project"
cross_dir = root / "B06_Section" / "cross_sections"
cross_dir.mkdir(parents=True)
longitudinal_path = root / "B06_Section" / "longitudinal" / "longitudinal.json"
longitudinal_path.parent.mkdir(parents=True)
stations, designs = [], {}
for chainage, slope in ((0, 0.3), (20, 0.5), (40, 0.4)):
stations.append(
{"chainage_m": float(chainage), "center_x": 0.0, "center_y": float(chainage)}
)
source = {
"chainage_m": float(chainage),
"samples": _samples(slope),
# +y 진행의 좌측 = -x
"frame": {"origin": {"x": 0.0, "y": float(chainage)}, "left_xy": [-1.0, 0.0]},
}
(cross_dir / f"cross_{chainage:05d}m.json").write_text(json.dumps(source), "utf-8")
designs[chainage] = compute_cross_design(
source["samples"], 100.0, ground_type="soil", section_mode="left_cut"
)
longitudinal = {"stations": stations}
longitudinal_path.write_text(json.dumps(longitudinal), "utf-8")
return longitudinal_path, longitudinal, designs
def _meets_ground(design: dict, side: str) -> float:
"""설계선이 바깥으로 가며 지반과 다시 같아지는 첫 오프셋 — 시험 쪽 독립 계산."""
edge = design["road_edges"][side]["offset_m"]
line = sorted((p["offset_m"], p["elevation_m"]) for p in design["design_line"])
outward = (
[p for p in line if p[0] > edge]
if side == "left"
else [p for p in line[::-1] if p[0] < edge]
)
slope = SLOPES[design["_c"]]
for offset, elevation in outward:
# 지반은 직선 비탈이라 어느 오프셋에서나 식으로 바로 잰다(설계선 꺾임점 포함).
if abs(100.0 + slope * offset - elevation) < 1e-3:
return offset
raise AssertionError("설계선이 지반에 닿지 않음")
SLOPES = {0: 0.3, 20: 0.5, 40: 0.4}
def test_outline_width_equals_cross_design_at_two_stations(tmp_path: Path) -> None:
longitudinal_path, longitudinal, designs = _project(tmp_path)
records = slope_records(longitudinal_path, longitudinal, designs, 0.0, 40.0)
route = [(0.0, 0.0, 0.0), (40.0, 0.0, 40.0)]
edge, fill, cut, ticks = slope_pieces(route, records, 0.0, 40.0)
left_edge, right_edge = edge["lines"]
# 좌(+)가 높은 지반 — 좌측 비탈은 절토 · 우측은 성토, 각각 한 줄로 이어짐
assert all(r["left_cut"] and not r["right_cut"] for r in records)
(left_end,), (right_end,) = cut["lines"], fill["lines"]
for chainage in (20, 40): # 견본 측점 둘
design = {**designs[chainage], "_c": chainage}
index = next(i for i, p in enumerate(left_edge) if abs(p[1] - chainage) < 1e-6)
expected = {
"left_edge": design["road_edges"]["left"]["offset_m"],
"left_end": _meets_ground(design, "left"),
"right_edge": design["road_edges"]["right"]["offset_m"],
"right_end": _meets_ground(design, "right"),
}
# 좌측은 -x 쪽 — 중심(0, c)에서 잰 거리가 횡단 설계 오프셋이다.
assert -left_edge[index][0] == pytest.approx(expected["left_edge"], abs=1e-3)
assert -left_end[index][0] == pytest.approx(expected["left_end"], abs=1e-3)
assert -right_edge[index][0] == pytest.approx(expected["right_edge"], abs=1e-3)
assert -right_end[index][0] == pytest.approx(expected["right_end"], abs=1e-3)
# 성토 · 절토 비탈이 실제로 노견 밖에 있다
assert abs(expected["left_end"]) > abs(expected["left_edge"])
assert abs(expected["right_end"]) > abs(expected["right_edge"])
# 비탈 눈금은 노견 끝에서 바깥으로 · 긴 것과 짧은 것이 섞임
lengths = {round(math.dist(*tick), 2) for tick in ticks["ticks"]}
assert len(ticks["ticks"]) > 20 and len(lengths) > 2
def test_kinds_add_delete_persist(tmp_path: Path) -> None:
root = tmp_path / "project"
assert [k["name"] for k in list_kinds(root)] == ["지형도"] # 기본 = 하나
assert [k["id"] for k in DEFAULT_KINDS] == ["terrain"]
assert "refuge" not in DEFAULT_KINDS[0]["pieces"]
added = add_kind(root, "내 평면도", ["centerline", "station", "nope"])
assert added["pieces"] == ["centerline", "station"]
with pytest.raises(ValueError):
add_kind(root, "내 평면도", ["centerline"]) # 같은 이름
with pytest.raises(ValueError):
add_kind(root, "빈 것", [])
delete_kind(root, "terrain") # 기본 종류도 지움
again = list_kinds(root) # 다시 읽어 같음
assert [k["name"] for k in again] == ["내 평면도"]
assert again[-1]["pieces"] == ["centerline", "station"]
with pytest.raises(FileNotFoundError):
delete_kind(root, "terrain")
delete_kind(root, again[0]["id"])
assert list_kinds(root) == [] # 다 지우면 비어 있음(기본이 되살아나지 않음)
def test_drawing_has_only_chosen_pieces() -> None:
pieces = {
"centerline": {"lines": [[[0.0, 0.0], [0.0, 40.0]]]},
"radius": {"items": [{"label": "R=15", "at": [1.0, 20.0]}]},
}
drawing = build_plan_kind_drawing(
"plan_k1", "계획평면도(시험)", (-50.0, -10.0, 50.0, 50.0), pieces
)
layers = {entity.get("layerId") for entity in drawing["entities"]}
assert layer_id("centerline") in layers and layer_id("radius") in layers
assert layer_id("contour") not in layers and layer_id("station") not in layers
labels = [e["shapeData"]["label"] for e in drawing["entities"] if e["type"] == "Text"]
assert "R=15" in labels and "계획평면도(시험)" in labels
def test_split_runs_switch_cut_fill_and_stay_joined() -> None:
route = [(0.0, 0.0, 0.0), (40.0, 0.0, 40.0)]
base = {"left_edge_m": 2.0, "left_end_m": 5.0, "right_edge_m": -2.0, "right_end_m": -5.0}
records = [
{"chainage_m": 0.0, **base, "left_cut": True, "right_cut": False},
{"chainage_m": 40.0, **base, "left_cut": False, "right_cut": False},
]
_edge, fill, cut, _ticks = slope_pieces(route, records, 0.0, 40.0)
(cut_left,) = cut["lines"]
fill_left = next(line for line in fill["lines"] if line[0][0] < 0)
assert cut_left[-1] == fill_left[0] # 바뀌는 자리에서 두 선이 이어짐
assert cut_left[0][1] == 0.0 and fill_left[-1][1] == 40.0
def test_default_kinds_keep_old_picture_and_legacy_file(tmp_path: Path) -> None:
root = tmp_path / "project"
outline = {"road_edge", "fill_outline", "cut_outline"}
# 옛 slope_outline 자리를 나눈 셋이 그대로 채움 · 배수시설은 옛 그림에 없음(용지도는 따로)
for kind in DEFAULT_KINDS:
assert outline <= set(kind["pieces"]) and "drainage" not in kind["pieces"]
folder = _kinds_dir(root)
folder.mkdir(parents=True)
(folder / "old.json").write_text(
json.dumps({"name": "옛것", "pieces": ["slope_outline", "station"], "order": 0}), "utf-8"
)
kind = next(k for k in list_kinds(root) if k["id"] == "old")
assert set(kind["pieces"]) == outline | {"station"}
def test_update_kind_default_and_release_confirmed(tmp_path: Path) -> None:
root = tmp_path / "project"
route = add_kind(root, "노선배치도", ["centerline"])["id"]
manifest = _read_manifest(root)
manifest["drawings"] = {
"plan_terrain": {"confirmed": True},
f"plan_{route}": {"confirmed": True},
}
_write_manifest(root, manifest)
update_kind(root, "terrain", "지형2", ["contour", "centerline"]) # 기본 종류 고침
again = {k["id"]: k for k in list_kinds(root)} # 다시 읽어 같음
assert again["terrain"] == {
"id": "terrain",
"name": "지형2",
"pieces": ["contour", "centerline"],
"layout": "plan",
"scale": 1200,
}
assert [k["id"] for k in list_kinds(root)][0] == "terrain" # 차례 그대로
drawings = _read_manifest(root)["drawings"]
assert drawings["plan_terrain"]["confirmed"] is False
assert drawings[f"plan_{route}"]["confirmed"] is True
with pytest.raises(ValueError):
update_kind(root, "terrain", "노선배치도", ["contour"]) # 다른 종류와 같은 이름
with pytest.raises(ValueError):
update_kind(root, "terrain", "지형", [])
with pytest.raises(FileNotFoundError):
update_kind(root, "nokind", "없음", ["contour"])
def test_drainage_symbol_piece() -> None:
pieces = {"drainage": {"items": [{"x": 0.0, "y": 20.0, "label": "D800"}]}}
drawing = build_plan_kind_drawing("plan_k2", "시험", (-50.0, -10.0, 50.0, 50.0), pieces)
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")}
# 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")}
def test_landuse_pieces_become_layers() -> None:
pieces = {
"parcel": {"lines": [[[0.0, 0.0], [10.0, 0.0]]]},
"emd": {"lines": [[[0.0, 5.0], [10.0, 5.0]]]},
"sgg": {"lines": [[[0.0, 9.0], [10.0, 9.0]]]},
"parcel_info": {
"items": [
{"lines": ["1-1", "임야", "", "국"], "anchor": [5.0, 2.0], "center": [20.0, 20.0]}
]
},
}
drawing = build_plan_kind_drawing(
"plan_landuse", "평면도(용지도)", (-50.0, -10.0, 50.0, 50.0), pieces
)
names = {layer["id"] for layer in drawing["layers"]}
assert {layer_id(p) for p in ("parcel", "parcel_info", "emd", "sgg")} <= names
labels = [
e["shapeData"]["label"]
for e in drawing["entities"]
if e.get("layerId") == layer_id("parcel_info") and e["type"] == "Text"
]
assert labels == ["1-1", "임야", "국"]
def test_watershed_kind_layout_one_sheet(tmp_path: Path) -> None:
"""배수유역도 = 새 종류 · 양식 watershed · 조각 없음 · 장이 여럿이어도 목록은 한 장."""
root = tmp_path / "project"
added = add_kind(root, "배수유역도", [], "watershed")
assert next(k for k in list_kinds(root) if k["id"] == added["id"]) == {
"id": added["id"],
"name": "배수유역도",
"pieces": [],
"layout": "watershed",
"scale": 6000,
"contour_interval_m": 5, # 512-1 유역도 등고선 간격 칸(기본 5 m)
}
# 측점 0 ~ 2,000 m — 1/1,200 평면도는 여러 장 · 유역도는 한 장
stations = [
{"chainage_m": float(c), "center_x": 0.0, "center_y": float(c)} for c in range(0, 2001, 20)
]
items = plan_kind_items(root, {"stations": stations})
terrain = [item for item in items if item["plan_kind"] == "terrain"]
mine = [item for item in items if item["plan_kind"] == added["id"]]
assert len(terrain) > 1
assert mine == [
{
"id": f"plan_{added['id']}",
"label": "배수유역도",
"plan_kind": added["id"],
"plan_kind_name": "배수유역도",
}
]
def test_watershed_kind_delete_and_add_again(tmp_path: Path) -> None:
"""× 로 지우면 안 살아남 · 「새 종류」 양식 유역도로 다시 만듦 · 업데이트는 양식 그대로."""
root = tmp_path / "project"
first = add_kind(root, "배수유역도", [], "watershed")
delete_kind(root, first["id"])
assert first["id"] not in [k["id"] for k in list_kinds(root)]
assert first["id"] not in [k["id"] for k in list_kinds(root)] # 다시 읽어도 안 살아남
added = add_kind(root, "배수유역도", ["contour"], "watershed") # 조각은 버림
assert added["layout"] == "watershed" and added["pieces"] == []
again = next(k for k in list_kinds(root) if k["id"] == added["id"])
assert again == added
assert any(
item["id"] == f"plan_{added['id']}" for item in plan_kind_items(root, {"stations": []})
)
update_kind(root, added["id"], "유역도2", []) # 유역도 양식은 조각 없이 업데이트
assert next(k for k in list_kinds(root) if k["id"] == added["id"])["layout"] == "watershed"
with pytest.raises(ValueError):
add_kind(root, "이상한 양식", ["contour"], "nope")
def test_ip_table_two_lines_left_to_right_top_to_bottom() -> None:
"""한 줄 셋까지 들어가는 폭 · 묶음 넷 — 첫 줄(1 · 2 · 3) 맨 위 왼 → 오 · 넷째는 아랫줄 왼쪽."""
blocks = [{"name": str(i), "ia": 1.0} for i in range(1, 5)]
block_w = 7.0 + 6.0 + 23.0
_place(blocks, 2 * 5.0 + 3 * block_w + 1.0)
origins = [b["origin"] for b in blocks]
tops = [o[1] for o in origins]
assert tops[0] == tops[1] == tops[2] > tops[3] # 첫 줄이 위
assert origins[0][0] < origins[1][0] < origins[2][0] # 왼 → 오
assert origins[3][0] == origins[0][0] # 다음 줄은 다시 왼쪽
assert tops[3] == 5.0 + 8 * 5.0 # 마지막 줄이 도곽 왼쪽 아래 기준(옛 한 줄 자리와 같음)
assert tops[0] - tops[3] == 8 * 5.0 + 3.0 # 줄 간격 그대로
def test_reorder_kinds_order_and_release_renumbered(tmp_path: Path) -> None:
root = tmp_path / "project"
second, third = (add_kind(root, name, ["centerline"])["id"] for name in ("둘째", "셋째"))
manifest = _read_manifest(root)
manifest["drawings"] = {
"plan_terrain": {"confirmed": True},
f"plan_{second}": {"confirmed": True},
f"plan_{third}": {"confirmed": True},
}
_write_manifest(root, manifest)
before = [k["id"] for k in list_kinds(root)]
assert before == ["terrain", second, third]
after = [second, "terrain", third] # 앞 둘만 바꿈
reorder_kinds(root, after)
assert [k["id"] for k in list_kinds(root)] == after # 다시 읽어 같음
drawings = _read_manifest(root)["drawings"]
assert drawings["plan_terrain"]["confirmed"] is False # 번호 바뀜
assert drawings[f"plan_{second}"]["confirmed"] is False
assert drawings[f"plan_{third}"]["confirmed"] is True # 앞선 종류 묶음 그대로 = 번호 그대로
with pytest.raises(ValueError):
reorder_kinds(root, after[:-1]) # 빠진 종류
with pytest.raises(ValueError):
reorder_kinds(root, [*after[:-1], "terrain"]) # 겹친 종류