- 좌표 낡음 판정 — 좌표가 그 누가거리를 안 낳으면(마커 · 방향키 · 폼으로 누가거리만 옮김) 노선 위 자리로 다시 적음 · TS placeOnLine ↔ 파이썬 fill_structure_coordinates 짝(보낸 좌표가 누가거리를 낳으면 살림) - 따라감 단계 — 좌표 없는 관 초안은 앞 따라감 좌표 · site 간격 넘김 - 배수 편집기 — 노선 초안 follow 가 다시 매긴 관 자리를 id 짝으로 받음(시설 속성 · 고름 함께) · 초안 버림 = 저장분 다시 읽음 - 관 짝 차례 → id — 관 알림에 고정 id · followPipeReports · shownPipes(movePipeIds 한 번에 옮김) · followedMarks 같은 자리 먼저 - B06 측점 열쇠 — 서버 관 id 도 pipe: (계산 묶음과 같은 갈래) - 시험 test_410_follow_xy · test_411_b_pipe_id_pairing Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
204 lines
8.1 KiB
Python
204 lines
8.1 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""410 구조물 · 관 자리 = 등고선 좌표(x · y) 지킴 — 교점 옮김 · R 바꿈 뒤 좌표 그대로 ·
|
|
누가거리 다시 매김.
|
|
|
|
거울(파이썬 = 서버 정본 · TS = 브라우저 즉시 · 같은 값):
|
|
① 좌표 낡음 판정 — `common_util_route_follow.fill_structure_coordinates` ↔ TS `placeOnLine`
|
|
(좌표가 그 누가거리를 낳으면 살림 · 누가거리만 옮긴 구조물 = 그 누가거리의 노선 위 자리).
|
|
② 따라감 — `follow_structures` ↔ `followStructures`(L1 → L2 교점 옮김 → L3 곡선 바꿈).
|
|
③ 관 — 좌표 없는 초안 관은 앞 따라감(같은 id · 같은 자리)의 좌표 · 옮긴 관은 노선 위 자리.
|
|
핵심: 마커로 옮긴 구조물은 다음 노선 고침에 옛 자리로 안 튐 · 안 옮긴 구조물 · 관은 좌표 그대로.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from shapely.geometry import LineString, Point
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
ROOT = HERE.parents[1]
|
|
if str(ROOT) not in sys.path:
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from common_util.common_util_drainage_pipes import PipePoint, project_pipe_points # noqa: E402
|
|
from common_util.common_util_route_follow import ( # noqa: E402
|
|
fill_structure_coordinates,
|
|
follow_structures,
|
|
)
|
|
from common_util.common_util_route_geometry import RouteVertex # noqa: E402
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
|
|
reason="프론트엔드 의존성 미설치",
|
|
)
|
|
|
|
L1 = [(0.0, 0.0), (100.0, 0.0), (200.0, 50.0)]
|
|
L2 = [(0.0, 0.0), (100.0, 12.0), (200.0, 50.0)] # 교점 옮김
|
|
L3 = [(0.0, 0.0), (85.0, 4.0), (100.0, 10.0), (115.0, 18.0), (200.0, 50.0)] # 곡선(R) 바꿈
|
|
LINES = [L1, L2, L3]
|
|
INTERVAL = 20.0
|
|
|
|
|
|
def _at(line, chainage, offset=0.0):
|
|
"""노선 위 누가거리 자리 + 왼쪽 법선 offset(m)."""
|
|
geo = LineString(line)
|
|
p = geo.interpolate(chainage)
|
|
q = geo.interpolate(min(chainage + 0.01, geo.length))
|
|
dx, dy = q.x - p.x, q.y - p.y
|
|
norm = (dx * dx + dy * dy) ** 0.5 or 1.0
|
|
return p.x - dy / norm * offset, p.y + dx / norm * offset
|
|
|
|
|
|
def _structure(sid, placement, chainage, xy, start=None, end=None):
|
|
return {
|
|
"structure_id": sid,
|
|
"placement": placement,
|
|
"chainage_m": chainage,
|
|
"start_m": start,
|
|
"end_m": end,
|
|
"x": xy[0],
|
|
"y": xy[1],
|
|
}
|
|
|
|
|
|
# S1 = 노선 밖 5 m 땅 자리(안 옮김) · S2 = 140 m 에 놓았다 마커로 60 m 로 옮김(좌표 옛 값) ·
|
|
# S3 = 구간(안 옮김 · 3 m 밖) · S4 = 사토장(측점 80 · 땅 자리 84 m) ·
|
|
# S5 = 구간을 폼으로 옮김(좌표 옛 값)
|
|
STRUCTURES = [
|
|
_structure("S1", "point", 50.0, _at(L1, 50.0, 5.0)),
|
|
_structure("S2", "point", 60.0, _at(L1, 140.0)),
|
|
_structure("S3", "interval", 120.0, _at(L1, 120.0, -3.0), 110.0, 135.0),
|
|
_structure("S4", "site", 80.0, _at(L1, 84.0, 2.0)),
|
|
_structure("S5", "interval", 30.0, _at(L1, 170.0), 25.0, 40.0),
|
|
]
|
|
|
|
# 관 — p1 = 앞 따라감 그대로(좌표 없는 초안 · 같은 자리) · p2 = 초안에서 옮김(앞 따라감과 자리 다름)
|
|
FOLLOWED = [
|
|
{"id": "p1", "chainage_m": 70.0, "x": _at(L1, 70.0, 1.5)[0], "y": _at(L1, 70.0, 1.5)[1]},
|
|
{"id": "p2", "chainage_m": 150.0, "x": _at(L1, 150.0)[0], "y": _at(L1, 150.0)[1]},
|
|
]
|
|
PIPES = [{"id": "p1", "chainage_m": 70.0}, {"id": "p2", "chainage_m": 30.0}]
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def ts(tmp_path_factory) -> dict:
|
|
source = tmp_path_factory.mktemp("s410") / "in.json"
|
|
source.write_text(
|
|
json.dumps(
|
|
{
|
|
"lines": LINES,
|
|
"structures": STRUCTURES,
|
|
"interval": INTERVAL,
|
|
"pipes": PIPES,
|
|
"followed": FOLLOWED,
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
|
["node", str(HERE / "helper_410_follow_xy.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)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def py() -> list[dict]:
|
|
"""서버 차례 — 저장(좌표 채움 · L1) → 노선 저장(따라감 · L2) → 저장(L2) → 노선 저장(L3)."""
|
|
items = [SimpleNamespace(**item) for item in STRUCTURES]
|
|
steps = []
|
|
for index in range(len(LINES) - 1):
|
|
fill_structure_coordinates(items, LINES[index])
|
|
placed = [dict(vars(item)) for item in items]
|
|
follow_structures(items, LINES[index + 1])
|
|
steps.append({"placed": placed, "followed": [dict(vars(item)) for item in items]})
|
|
return steps
|
|
|
|
|
|
def _by_id(rows):
|
|
return {row["structure_id"]: row for row in rows}
|
|
|
|
|
|
def _close(a, b, tol=1e-3):
|
|
return (a is None and b is None) or abs(float(a) - float(b)) <= tol
|
|
|
|
|
|
@pytest.mark.parametrize("step", [0, 1])
|
|
def test_파이썬_TS_같은_값(ts, py, step):
|
|
for phase in ("placed", "followed"):
|
|
got, want = _by_id(ts["steps"][step][phase]), _by_id(py[step][phase])
|
|
for sid, row in want.items():
|
|
for key in ("chainage_m", "start_m", "end_m", "x", "y"):
|
|
assert _close(got[sid].get(key), row[key]), (step, phase, sid, key)
|
|
|
|
|
|
def test_안_옮긴_구조물_좌표_그대로(py):
|
|
for sid in ("S1", "S3", "S4"):
|
|
first = _by_id(py[0]["placed"])[sid]
|
|
for step in py:
|
|
for phase in ("placed", "followed"):
|
|
now = _by_id(step[phase])[sid]
|
|
assert _close(now["x"], first["x"]) and _close(now["y"], first["y"]), sid
|
|
|
|
|
|
def test_누가거리는_새_노선_투영(py):
|
|
for step, line in zip(py, LINES[1:]):
|
|
geo = LineString(line)
|
|
for sid in ("S1", "S2"):
|
|
row = _by_id(step["followed"])[sid]
|
|
assert row["chainage_m"] == float(round(geo.project(Point(row["x"], row["y"]))))
|
|
# 사토장 = 새 노선 일반 측점 중 가장 가까운 곳
|
|
site = _by_id(step["followed"])["S4"]
|
|
assert site["chainage_m"] % INTERVAL == 0
|
|
|
|
|
|
def test_마커로_옮긴_구조물은_옛_자리로_안_튐(py):
|
|
moved_xy = _at(L1, 60.0)
|
|
for step in py:
|
|
for phase in ("placed", "followed"):
|
|
row = _by_id(step[phase])["S2"]
|
|
assert _close(row["x"], moved_xy[0]) and _close(row["y"], moved_xy[1])
|
|
assert 50 <= row["chainage_m"] <= 70 # 옛 자리 140 m 근처로 안 돌아감
|
|
form = _by_id(py[1]["followed"])["S5"]
|
|
assert 20 <= form["chainage_m"] <= 40 and form["end_m"] - form["start_m"] == 15
|
|
|
|
|
|
def test_서버는_보낸_좌표가_누가거리를_낳으면_살림():
|
|
# 노선 고친 초안에서 옮긴 구조물 — 옛 정본과 자리가 달라도 보낸 좌표(노선 밖 4 m)가
|
|
# 그 누가거리를 낳음
|
|
xy = _at(L2, 90.0, 4.0)
|
|
item = SimpleNamespace(**_structure("S9", "point", 90.0, xy))
|
|
old = SimpleNamespace(**_structure("S9", "point", 140.0, _at(L2, 140.0)))
|
|
fill_structure_coordinates([item], L2, previous={"S9": old})
|
|
assert (item.x, item.y) == xy
|
|
stale = SimpleNamespace(**_structure("S9", "point", 30.0, xy)) # 누가거리만 옮김 = 낡은 좌표
|
|
fill_structure_coordinates([stale], L2, previous={"S9": old})
|
|
assert _close(stale.x, _at(L2, 30.0)[0]) and _close(stale.y, _at(L2, 30.0)[1])
|
|
|
|
|
|
def test_관_초안은_앞_따라감_좌표_옮긴_관은_노선_위(ts):
|
|
placed = {row["id"]: row for row in ts["pipesPlaced"]}
|
|
assert _close(placed["p1"]["x"], FOLLOWED[0]["x"]) and _close(
|
|
placed["p1"]["y"], FOLLOWED[0]["y"]
|
|
)
|
|
assert _close(placed["p2"]["x"], _at(L1, 30.0)[0]) and _close(placed["p2"]["y"], 0.0)
|
|
vertices = [RouteVertex(x=x, y=y, z=0.0, chainage_m=0.0) for x, y in L2]
|
|
points = [
|
|
PipePoint(chainage_m=70.0, x=FOLLOWED[0]["x"], y=FOLLOWED[0]["y"], id="p1"),
|
|
PipePoint(chainage_m=30.0, x=_at(L1, 30.0)[0], y=0.0, id="p2"),
|
|
]
|
|
want = {point.id: point.chainage_m for point in project_pipe_points(points, vertices)}
|
|
got = {row["id"]: row["chainage_m"] for row in ts["pipesFollowed"]}
|
|
assert got == want
|