# -*- coding: utf-8 -*- """493-3 화면 ↔ 저장 자리 거울 — 교점 끌기 여러 걸음 뒤 화면 시설 자리 = [저장] 뒤 서버 자리. 브라우저(TS) = 걸음마다 앞 선에서 자리 붙임 → 새 선 따라감 (`helper_493_3_follow_steps.cjs`) · 서버(파이썬) = 저장 노선(L0)의 정본에서 마지막 선(Ln)으로 한 번 (`B05_Profile_Route_Save.followed_pipes` · `B05_Profile_Structures_Router.followed_structures` · `B05_Profile_Plus_Stations.followed_plus_rows` 와 같은 셈). 규칙(요구 290) = 관 · 구조물 · 추가 측점 = 땅 위 자리(x · y)에 가장 가까운 새 노선 자리. 493-4 = 관 땅 위 자리(x · y) 자체 — 화면 관 좌표 = 관 PUT(`put_pipe_points` · 속성 되붙임 `carry_facility_attributes` → `save_pipe_points`) 뒤 정본 좌표 · 두 번 고쳐 저장해도 그대로 · 누가거리만 옮긴 관 = 노선 위. 경우 = 직선 · 곡선 R · 교점 더함 · 지움 · 노선 앞 교점 옮김(여러 걸음 · 오가며). 노선 = 서버 `build_planned_polyline`(브라우저 `draftLine` 짝 · test_route_polyline_browser_mirror.py) · 4자리(계획노선 csv · `draftLine` `xy4`). """ from __future__ import annotations import copy import json import math import subprocess import sys from pathlib import Path from types import SimpleNamespace import pytest HERE = Path(__file__).resolve().parent ROOT = HERE.parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from B05_Profile.B05_Profile_Plus_Stations import followed_plus_rows # noqa: E402 from B05_Profile.B05_Profile_Route_Save_Parts import follow_pipes, vertices_of # noqa: E402 from common_util.common_util_drainage_pipes import ( # noqa: E402 PipePoint, fill_pipe_coordinates, ) from common_util.common_util_route_follow import ( # noqa: E402 fill_structure_coordinates, follow_structures, ) from common_util.common_util_route_polyline import build_planned_polyline # noqa: E402 from config.config_system import SECTION_STATION_INTERVAL_M # noqa: E402 pytestmark = pytest.mark.skipif( not (ROOT / "config" / "node_modules" / "typescript").is_dir(), reason="프론트엔드 의존성 미설치", ) NODES = [ (0.0, 0.0), (60.0, 8.0), (110.0, 52.0), (175.0, 58.0), (215.0, 15.0), (290.0, 22.0), (330.0, 80.0), ] RADII: list[float | None] = [None, 30.0, 12.0, 25.0, 12.0, 40.0, None] def _line(nodes, radii=None) -> list[list[float]]: radii = list(RADII if radii is None else radii) built = build_planned_polyline( list(nodes), min_radius_m=12.0, simplify=False, curve_flags=[True] * len(nodes), radii=radii, ) return [[round(x, 4), round(y, 4)] for x, y in built.vertices] def _at(line, chainage, offset=0.0) -> tuple[float, float]: walked = 0.0 for (ax, ay), (bx, by) in zip(line, line[1:]): span = math.hypot(bx - ax, by - ay) if span > 0 and walked + span >= chainage: t = (chainage - walked) / span nx, ny = -(by - ay) / span, (bx - ax) / span return ax + (bx - ax) * t + nx * offset, ay + (by - ay) * t + ny * offset walked += span return tuple(line[-1]) def _drag(seat: int, to: tuple[float, float], steps: int, radii=None, nodes=None): """교점 `seat` 를 `to` 만큼 여러 걸음으로 옮긴 선들(오가며 — 끝 앞에서 한 번 넘쳤다 돌아옴).""" nodes = list(nodes or NODES) x0, y0 = nodes[seat] path = [k / steps for k in range(1, steps + 1)] + [1.15, 0.85, 1.0] lines = [] for f in path: moved = list(nodes) moved[seat] = (x0 + to[0] * f, y0 + to[1] * f) lines.append(_line(moved, radii)) return lines L0 = _line(NODES) CASES = { # 노선 앞 교점 옮김 — 뒤 시설 누가거리가 걸음마다 바뀜(483 · 482 보고 「굽은 곳 2~7 m」) "앞_교점_옮김": [L0, *_drag(1, (-9.0, 13.0), 12)], "곡선_교점_옮김": [L0, *_drag(2, (7.0, -11.0), 9)], "R_바꿈": [ L0, _line(NODES, [None, 30.0, 35.0, 25.0, 12.0, 40.0, None]), _line(NODES, [None, 30.0, 60.0, 25.0, 12.0, 40.0, None]), ], "교점_더함": [ L0, _line([*NODES[:2], (85.0, 20.0), *NODES[2:]], [*RADII[:2], 15.0, *RADII[2:]]), ], "교점_지움": [L0, _line([*NODES[:2], *NODES[3:]], [*RADII[:2], *RADII[3:]])], "직선": [L0, _line([NODES[0], (70.0, -4.0), NODES[-1]], [None, 30.0, None])], } # 저장 정본(L0 위) — 관 = 세류선 교차(노선 옆 0.2 ~ 0.4 m) · 구간 관 · # 구조물 = 점 · 구간 · 사토장 · 추가 측점 = 정수 m PIPES = [ {"id": "p1", "chainage_m": 75.0, "x": _at(L0, 75.0, 0.3)[0], "y": _at(L0, 75.0, 0.3)[1]}, {"id": "p2", "chainage_m": 141.0, "x": _at(L0, 141.0, -0.25)[0], "y": _at(L0, 141.0, -0.25)[1]}, { "id": "p3", "chainage_m": 236.0, "x": _at(L0, 236.0, 0.4)[0], "y": _at(L0, 236.0, 0.4)[1], "start_m": 233.0, "end_m": 240.0, }, {"id": "p4", "chainage_m": 330.0, "x": _at(L0, 330.0)[0], "y": _at(L0, 330.0)[1]}, ] STRUCTURES = [ {"structure_id": "s1", "placement": "point", "chainage_m": 128.0}, { "structure_id": "s2", "placement": "interval", "chainage_m": 205.0, "start_m": 198.0, "end_m": 214.0, }, {"structure_id": "s3", "placement": "site", "chainage_m": 280.0}, ] PLUS = [{"chainage_m": c} for c in (93.0, 157.0, 263.0, 301.0)] # 앞 노선 [저장]이 따라 옮긴 추가 측점 — 좌표 = 옛 땅 자리(노선 옆 · 누가거리 소수 자리) · # 누가거리 = 정수 m PLUS_FOLLOWED = {"chainage_m": 187.0, "at": (187.4, 0.8)} def _saved_structures() -> list[dict]: """서버 정본 꼴 — 저장 때 좌표 채움(`fill_structure_coordinates` · L0).""" items = [ SimpleNamespace(**{"start_m": None, "end_m": None, "x": None, "y": None, **s}) for s in STRUCTURES ] fill_structure_coordinates(items, L0) return [dict(vars(item)) for item in items] def _saved_plus() -> list[dict]: """서버 정본 꼴(`save_plus_stations`) — 누가거리의 L0 위 좌표 4자리 · 따라 옮긴 줄은 옛 땅 자리.""" rows = [] for row in PLUS: x, y = _at(L0, row["chainage_m"]) rows.append({"chainage_m": row["chainage_m"], "x": round(x, 4), "y": round(y, 4)}) x, y = _at(L0, *PLUS_FOLLOWED["at"]) rows.append({"chainage_m": PLUS_FOLLOWED["chainage_m"], "x": round(x, 4), "y": round(y, 4)}) return sorted(rows, key=lambda row: row["chainage_m"]) def _browser(lines, folder: Path, old_plus: bool = False) -> dict: source = folder / "in.json" source.write_text( json.dumps( { "lines": lines, "pipes": PIPES, "structures": _saved_structures(), "plus": _saved_plus(), "interval": SECTION_STATION_INTERVAL_M, "oldPlus": old_plus, } ), encoding="utf-8", ) proc = subprocess.run( # noqa: S603 — 고정 실행 파일 ["node", str(HERE / "helper_493_3_follow_steps.cjs"), str(source)], capture_output=True, text=True, encoding="utf-8", cwd=str(ROOT), check=False, ) assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) def _server(lines) -> dict: old, new = lines[0], lines[-1] pipes = [PipePoint(**{k: v for k, v in p.items()}) for p in copy.deepcopy(PIPES)] fill_pipe_coordinates(pipes, vertices_of(old)) moved = [pipe for _, pipe in follow_pipes(pipes, vertices_of(new))] items = [SimpleNamespace(**s) for s in _saved_structures()] fill_structure_coordinates(items, old, only_missing=True) follow_structures(items, new) plus = followed_plus_rows(_saved_plus(), new)["rows"] return { "pipes": [ {"id": p.id, "chainage_m": p.chainage_m, "start_m": p.start_m, "end_m": p.end_m} for p in moved ], "structures": [dict(vars(item)) for item in items], "plus": [row["chainage_m"] for row in plus], } def _xy_gap(line, a, b) -> float: """두 누가거리의 새 선 위 자리 차(m) — 판정 = 0.01 m 안.""" (ax, ay), (bx, by) = _at(line, float(a)), _at(line, float(b)) return math.hypot(ax - bx, ay - by) def _gaps(screen, server, new) -> list: gaps = [] by_id = {p["id"]: p for p in screen["pipes"]} for want in server["pipes"]: got = by_id[want["id"]] for key in ("chainage_m", "start_m", "end_m"): if want[key] is not None: gaps.append((f"관 {want['id']} {key}", got[key], want[key])) by_sid = {s["structure_id"]: s for s in screen["structures"]} for want in server["structures"]: got = by_sid[want["structure_id"]] for key in ("chainage_m", "start_m", "end_m"): if want[key] is not None: gaps.append((f"구조물 {want['structure_id']} {key}", got[key], want[key])) assert len(screen["plus"]) == len(server["plus"]), (screen["plus"], server["plus"]) for got, want in zip(screen["plus"], server["plus"]): gaps.append(("추가 측점", got, want)) return [(name, got, want) for name, got, want in gaps if _xy_gap(new, got, want) > 0.01] @pytest.mark.parametrize("case", list(CASES)) def test_화면_자리_저장_자리_같음(case, tmp_path): lines = CASES[case] bad = _gaps(_browser(lines, tmp_path), _server(lines), lines[-1]) assert not bad, bad def test_고치기_전_추가_측점은_걸음마다_밀림(tmp_path): """대조 — 좌표 없이 걸음마다 정수 m 에서 다시 놓던 셈(493-3 전)은 끌기 뒤 서버와 m 단위로 어긋남.""" lines = CASES["앞_교점_옮김"] bad = _gaps(_browser(lines, tmp_path, old_plus=True), _server(lines), lines[-1]) assert bad and all(name == "추가 측점" for name, _, _ in bad) assert max(abs(got - want) for _, got, want in bad) >= 2 # ── 493-4 관 땅 위 자리(x · y) — [저장] 관 PUT(세부유역 계산 → 속성 되붙임 → 저장)이 화면 좌표 그대로 ── def _put_pipes(draft: list[dict], new, tmp_path, monkeypatch, carry_xy: bool = True) -> dict: """관 PUT 길(`put_pipe_points`) — 본문 → 계산기가 돌려준 누가거리만의 관(`_rebuilt` 꼴) → 속성 되붙임 → 정본 저장(새 노선) → id → (x, y). `carry_xy=False` = 고치기 전(되붙임이 좌표를 버림).""" from common_util import common_util_storage as storage from common_util.common_util_drainage_pipes import ( carry_facility_attributes, parse_pipe_points, pipe_points_path, save_pipe_points, ) monkeypatch.setattr(storage, "STORAGE_BASE_DIR", str(tmp_path / "storage")) (tmp_path / "storage" / "p").mkdir(parents=True) points = parse_pipe_points(copy.deepcopy(draft)) rebuilt = [PipePoint(chainage_m=round(p.chainage_m, 2), source=p.source) for p in points] carried = carry_facility_attributes(rebuilt, points) if not carry_xy: for point in carried: point.x = point.y = None save_pipe_points("storage/p", "sig", carried, vertices_of(new)) saved = json.loads(pipe_points_path("storage/p").read_text(encoding="utf-8"))["points"] return {p["id"]: (p["x"], p["y"]) for p in saved} @pytest.mark.parametrize("case", list(CASES)) def test_관_저장_좌표_화면_좌표_같음(case, tmp_path, monkeypatch): """끌기 뒤 화면 관 땅 위 자리 = [저장](관 PUT) 뒤 정본 좌표(0.01 m 안).""" lines = CASES[case] screen = _browser(lines, tmp_path)["pipes"] saved = _put_pipes(screen, lines[-1], tmp_path, monkeypatch) bad = [ (p["id"], round(math.hypot(p["x"] - saved[p["id"]][0], p["y"] - saved[p["id"]][1]), 3)) for p in screen if math.hypot(p["x"] - saved[p["id"]][0], p["y"] - saved[p["id"]][1]) > 0.01 ] assert not bad, bad def test_관_두_번_고쳐_저장해도_자리_그대로(tmp_path, monkeypatch): """[저장] 정본 좌표에서 다시 끌어 [저장] — 땅 위 자리가 흘러가지 않음(처음 정본 0.01 m 안).""" first, second = CASES["앞_교점_옮김"], CASES["곡선_교점_옮김"] (tmp_path / "a").mkdir() screen = _browser(first, tmp_path / "a")["pipes"] saved = _put_pipes(screen, first[-1], tmp_path / "a", monkeypatch) again = [{**p, "x": saved[p["id"]][0], "y": saved[p["id"]][1]} for p in screen] lines = [first[-1], *second[1:]] source = tmp_path / "b" source.mkdir() (source / "in.json").write_text( json.dumps({"lines": lines, "pipes": again, "structures": [], "plus": [], "interval": 20}), encoding="utf-8", ) proc = subprocess.run( # noqa: S603 — 고정 실행 파일 ["node", str(HERE / "helper_493_3_follow_steps.cjs"), str(source / "in.json")], capture_output=True, text=True, encoding="utf-8", cwd=str(ROOT), check=False, ) assert proc.returncode == 0, proc.stderr final = _put_pipes(json.loads(proc.stdout)["pipes"], lines[-1], tmp_path / "b", monkeypatch) for pipe in PIPES: x, y = final[pipe["id"]] assert math.hypot(x - pipe["x"], y - pipe["y"]) <= 0.01, pipe["id"] def test_고치기_전_관_저장은_노선_위로_덮음(tmp_path, monkeypatch): """대조 — 되붙임이 좌표를 버리던 셈(493-4 전)은 노선 옆 관(0.2 ~ 0.4 m)을 노선 위 측점 자리로 덮음.""" lines = CASES["앞_교점_옮김"] screen = _browser(lines, tmp_path)["pipes"] saved = _put_pipes(screen, lines[-1], tmp_path, monkeypatch, carry_xy=False) gaps = [math.hypot(p["x"] - saved[p["id"]][0], p["y"] - saved[p["id"]][1]) for p in screen] assert max(gaps) >= 0.2 def test_누가거리만_옮긴_관은_노선_위로(tmp_path, monkeypatch): """배수유역 칸 등에서 누가거리만 옮긴 관(좌표 = 옛 자리) — 저장 = 새 누가거리의 노선 위 자리.""" new = L0 moved = {**PIPES[0], "chainage_m": 110.0} saved = _put_pipes([moved], new, tmp_path, monkeypatch) x, y = _at(new, 110.0) assert math.hypot(saved["p1"][0] - x, saved["p1"][1] - y) <= 0.01