"""B07 계획평면도 종류 · 정보 조각 (PLAN 60장). ① 측점 둘에서 성토사면 외곽선 폭 = 횡단 설계 값(노견 끝 · 설계선이 지반에 닿는 자리) ② 종류 더하기 · 지우기(기본 넷 포함) · 다시 읽어 같음 ③ 대피소 · 사토장 자리 — 한 자리에 둘 다 ④ 고른 조각만 도면 층으로 얹힘 ⑤ 조각 잘게(67장) — 노견선 · 성토 외곽선 · 절토 외곽선 따로 · 기본 넷 처음 체크 = 옛 그림 · 옛 종류 파일의 slope_outline 펼침 · 종류 업데이트(기본 넷 포함) · 확정본 풀림 · 배수시설 기호 """ 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_Io import _read_manifest, _write_manifest from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( _kinds_dir, _refuge_items, add_kind, delete_kind, list_kinds, read_marks, slope_records, update_kind, 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)] 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)] == ["지형", "노선배치도", "대피소", "라이다"] 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 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"} for ( kind ) in DEFAULT_KINDS: # 옛 slope_outline 자리를 나눈 셋이 그대로 채움 · 배수시설은 옛 그림에 없음 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,) = list_kinds(root) assert set(kind["pieces"]) == outline | {"station"} def test_update_kind_default_and_release_confirmed(tmp_path: Path) -> None: root = tmp_path / "project" manifest = _read_manifest(root) manifest["drawings"] = { "plan_terrain": {"confirmed": True}, "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"], } 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["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)