Files
Aislo/resources/tester/test_b07_plan_kinds.py
T
eomsangdonandClaude Opus 5.5 138e80c79f feat(B07): 계획평면도 종류 · 정보 조각 서버 (PLAN 60-1 · 60-2)
- 정보 조각(등고선 · 계류 · 중심선 · 성토사면 외곽선 · 비탈 눈금 · IP · R · 측점 NO · 시종점 · 대피소/사토장 · 라이다 배경)마다 장별 파일(plan_pieces/{장}/{조각}.json)
- 외곽선 = 노견 끝 + 설계선이 지반에 닿는 자리(B06 횡단 설계 결과) · 측점 자리는 횡단 파일 중심 · 좌축
- 종류 = 조각 고르기 목록(plan_kinds/{id}.json) · 기본 넷(지형 · 노선배치도 · 대피소 · 라이다) · 더하기 · 지우기 API
- 대피소 · 사토장 = 구조물 정본 refuge · spoil_bank + 사용자 지정 자리(plan_marks.json)
- 도면 목록 · 단건 조회를 종류 × 장으로 · 옛 plan_* id 그대로 이어짐

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
2026-10-06 17:51:40 +09:00

160 lines
7.1 KiB
Python

"""B07 계획평면도 종류 · 정보 조각 (PLAN 60장).
① 측점 둘에서 성토사면 외곽선 폭 = 횡단 설계 값(노견 끝 · 설계선이 지반에 닿는 자리)
② 종류 더하기 · 지우기(기본 넷 포함) · 다시 읽어 같음
③ 대피소 · 사토장 자리 — 한 자리에 둘 다
④ 고른 조각만 도면 층으로 얹힘
"""
import json
import math
from pathlib import Path
import pytest
from B06_Section.B06_Section_Engine_Design import compute_cross_design
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
DEFAULT_KINDS,
build_plan_kind_drawing,
layer_id,
slope_pieces,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import (
_refuge_items,
add_kind,
delete_kind,
list_kinds,
read_marks,
slope_records,
write_marks,
)
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)]
outline, ticks = slope_pieces(route, records, 0.0, 40.0)
left_edge, left_end, right_edge, right_end = outline["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)] == ["지형", "노선배치도", "대피소", "라이다"]
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")
for kind in list(again):
delete_kind(root, kind["id"])
assert list_kinds(root) == [] # 다 지우면 비어 있음(기본 넷이 되살아나지 않음)
assert {k["id"] for k in DEFAULT_KINDS} == {"terrain", "route", "layout", "lidar"}
def test_refuge_marks_same_spot(tmp_path: Path) -> None:
root = tmp_path / "project"
write_marks(root, [{"chainage_m": 20, "refuge": True, "spoil": True}, {"chainage_m": 5}])
assert read_marks(root) == [{"chainage_m": 20.0, "refuge": True, "spoil": True}]
route = [(0.0, 0.0, 0.0), (40.0, 0.0, 40.0)]
items = _refuge_items(root, route, 0.0, 40.0)["items"]
assert [item["label"] for item in items] == ["대피소 · 사토장"]
assert items[0]["y"] == pytest.approx(20.0)
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