diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index 6360da2e2..d2b7132b0 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -366,9 +366,10 @@ def _raw_payload( **point.as_dict(), "chainage_m": round(pipe.chainage_m, 2), "lonlat": list(to_lonlat(pipe.x, pipe.y)), - # 노선 위 자리(사업지 CRS) — 화면이 따라가기만 한 관을 가림(rebasePipeDraft · 428) - "x": round(float(pipe.x), 3), - "y": round(float(pipe.y), 3), + # 땅 위 자리(사업지 CRS · 정본 좌표 · 없으면 노선 위 자리) — 화면이 따라가기만 한 관을 + # 가림(rebasePipeDraft · 428) · 노선 위 자리를 주면 [저장] 다시 얹기가 정본을 덮음(493-4) + "x": round(float(pipe.x if point.x is None else point.x), 3), + "y": round(float(pipe.y if point.y is None else point.y), 3), "source": pipe.reason, } for pipe, point in zip(detail.pipes, points) diff --git a/common_util/common_util_drainage_pipes.py b/common_util/common_util_drainage_pipes.py index 5fe991313..9abe6d6ef 100644 --- a/common_util/common_util_drainage_pipes.py +++ b/common_util/common_util_drainage_pipes.py @@ -38,6 +38,7 @@ from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir from common_util.common_util_json import atomic_write_json from common_util.common_util_route_geometry import RouteVertex, interpolate_vertex from common_util.common_util_structure_station import ( + SAME_STATION_M, route_general_stations, snap_structure_station, ) @@ -269,6 +270,13 @@ def max_projection_shift(points: list[PipePoint], vertices: list[RouteVertex]) - ) +def _holds_station(point: PipePoint, vertices: list[RouteVertex]) -> bool: + """좌표를 이 노선에 따라가도 누가거리가 그대로인가(TS `holdsStation` 짝).""" + probe = PipePoint(chainage_m=float(point.chainage_m), x=point.x, y=point.y) + project_pipe_points([probe], vertices) + return abs(probe.chainage_m - float(point.chainage_m)) <= SAME_STATION_M + + def project_pipe_points(points: list[PipePoint], vertices: list[RouteVertex]) -> list[PipePoint]: """저장된 좌표를 주어진 노선에 투영해 누가거리를 다시 매긴다. @@ -482,6 +490,8 @@ def carry_facility_attributes(base: list[PipePoint], reference: list[PipePoint]) point.summary_item = ref.summary_item point.group_items = ref.group_items point.id = ref.id + # 땅 위 자리도 — 버리면 저장이 노선 위 측점 자리로 채워 정본 좌표를 덮음(493-4) + point.x, point.y = ref.x, ref.y return ensure_pipe_ids(base) @@ -494,9 +504,14 @@ def save_pipe_points( """관 지점을 정본 파일에 쓴다. 저장된 개수를 돌려준다. `vertices`를 주면 좌표가 빈 관을 그 노선 위 좌표로 채워 둔다 — 다음에 다른 선으로 - 읽어도 그 자리에 되놓을 수 있다. + 읽어도 그 자리에 되놓을 수 있다. 좌표가 그 누가거리를 안 낳으면(누가거리만 옮긴 낡은 좌표) + 노선 위로 · 낳으면 땅 위 자리 그대로(493-4 · 구조물 `fill_structure_coordinates` · TS + `placeOnLine` 같은 규칙). """ if vertices: + for point in points: + if point.x is not None and point.y is not None and not _holds_station(point, vertices): + point.x = point.y = None fill_pipe_coordinates(points, vertices) ensure_pipe_ids(points) path = pipe_points_path(stored_path) diff --git a/resources/tester/test_428_pipe_move_mirror.py b/resources/tester/test_428_pipe_move_mirror.py index b965b9bb0..355f8ddfc 100644 --- a/resources/tester/test_428_pipe_move_mirror.py +++ b/resources/tester/test_428_pipe_move_mirror.py @@ -229,4 +229,6 @@ def test_관_PUT_끝_측점_다시_세움_GET_좌표(): stand = inspect.getsource(basins._stand_pipe_stations) assert "ensure_structure_stations(project_id)" in stand and "except Exception" in stand raw = inspect.getsource(basins._raw_payload) - assert '"x": round(float(pipe.x), 3)' in raw and '"y": round(float(pipe.y), 3)' in raw + # GET 좌표 = 정본 땅 위 자리(없으면 노선 위 자리 · 493-4) + assert '"x": round(float(pipe.x if point.x is None else point.x), 3)' in raw + assert '"y": round(float(pipe.y if point.y is None else point.y), 3)' in raw diff --git a/resources/tester/test_493_3_follow_screen_save.py b/resources/tester/test_493_3_follow_screen_save.py index 1d1692836..d51165620 100644 --- a/resources/tester/test_493_3_follow_screen_save.py +++ b/resources/tester/test_493_3_follow_screen_save.py @@ -7,6 +7,10 @@ `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`). @@ -254,3 +258,91 @@ def test_고치기_전_추가_측점은_걸음마다_밀림(tmp_path): 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