- 원인 = 관 PUT 이 세부유역 계산을 거쳐 속성만 되붙임(carry_facility_attributes · x · y 빠짐) → 저장이 노선 위 측점 자리로 채움 + GET 관 x · y = 노선 위 자리라 [저장] 다시 얹기가 그 자리를 베낌 · 검증_sub2 재현 0.62 m(검증_sub1 1.27 m) - carry_facility_attributes 가 x · y 도 되붙임 · save_pipe_points = 좌표가 그 누가거리를 낳으면 땅 위 자리 그대로 · 누가거리만 옮긴 낡은 좌표는 노선 위(구조물 fill_structure_coordinates · TS placeOnLine 같은 규칙) - GET 관 지점 x · y = 정본 좌표(없으면 노선 위 자리) · 지도 lonlat 은 노선 위 그대로 - 거울 시험 test_493_3_follow_screen_save 에 관 x · y(끌기 뒤 화면 = 관 PUT 뒤 정본 · 두 번 고쳐도 그대로 · 누가거리만 옮긴 관 = 노선 위 · 고치기 전 대조 0.2 m 넘게 덮임) · test_428 GET 좌표 글 맞춤 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
349 lines
14 KiB
Python
349 lines
14 KiB
Python
# -*- 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
|