diff --git a/B05_Profile/B05_Profile_Route_Save.py b/B05_Profile/B05_Profile_Route_Save.py index 8207b29ca..fc4b175bb 100644 --- a/B05_Profile/B05_Profile_Route_Save.py +++ b/B05_Profile/B05_Profile_Route_Save.py @@ -19,8 +19,8 @@ API — `POST /api/projects/{id}/route/replan`(`B05_Profile_Router_Replan.replan - 관 정본을 지우지 않음 — 좌표를 따라감 · 새 세류선 교차점 기본 관 · 오목 지점 보충만 더함 (`B05_Profile_Route_Save_Parts`) · 배수유역 저장분은 노선 지문이 달라 「낡음」(B04 `_resolve`). - 계획고 = 바뀐 구간만 새 지반 첫 계획선 · 그 밖 사용자 계획고 지킴 - (`B05_Profile_Engine_Grade_Carry`) · 추천 「종단 반영」 켬이면 균형 종단 - (`apply_profile_target`). + (`B05_Profile_Engine_Grade_Carry` · 바뀐 구간 = 옛 · 새 **계획 폴리라인** — 화면 planline 과 + 같은 선) · 균형 종단(`apply_profile_target`)은 추천 [적용](`as_initial`)만. - 초기값은 여기서 안 뜸 — 부르는 쪽이 `as_initial` 일 때만(자동설계 체인 직후 + 추천 [적용]). """ @@ -345,8 +345,13 @@ async def run_route_save_chain( points: list[dict[str, float]], previous_params: dict[str, Any], profile_target: tuple[list[float], list[float]] | None = None, + planned_lines: tuple[list[list[float]], list[list[float]]] | None = None, ) -> dict[str, Any]: - """노선 저장 체인 — 성공이면 요약 dict · 실패면 `{"error": 사유}`(파일 · 행은 되살린 뒤).""" + """노선 저장 체인 — 성공이면 요약 dict · 실패면 `{"error": 사유}`(파일 · 행은 되살린 뒤). + + `planned_lines` = (옛 · 새 계획 폴리라인) — 바뀐 구간을 화면 planline 단계와 같은 선으로 가름 + (없으면 풀린 노선 geojson · 점 간격이 달라 앞머리가 일찍 갈림). + """ import aiomysql from B03_FileInput.B03_FileInput_Service_Chain import _log_steps, redesign_solve_request @@ -445,8 +450,7 @@ async def run_route_save_chain( relative, new_route_id, old_long.get("profile_alignment") or {}, - old_xy, - new_xy, + *(planned_lines or (old_xy, new_xy)), ) marks.append(("계획고 옮김", time.perf_counter())) diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 428c49c03..b7ea83c8c 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -93,6 +93,15 @@ def _vertices_of(path: Path) -> list[list[float]]: return [[float(vertex.x), float(vertex.y)] for vertex in route.vertices] +def _planned_line(project_root: Path) -> list[list[float]]: + """화면이 받는 저장 노선(`read_route_plan` planned) — 수정본 · 초기 폴리라인 · 예상노선 차례.""" + return ( + _vertices_of(planned_route_working_path(project_root)) + or _vertices_of(planned_route_initial_path(project_root)) + or _vertices_of(expected_route_csv_path(project_root)) + ) + + def _ensure_expected_route(project_root: Path) -> str: """예상노선(원본) 정본이 없으면 만들어 둔다. 어디서 씨앗을 얻었는지 돌려준다. @@ -327,10 +336,12 @@ async def _recompute( stored_path: str, balanced: RouteInitialRequest | None = None, terrain: Literal["las", "sheet"] | None = None, + planned_lines: tuple[list[list[float]], list[list[float]]] | None = None, ) -> dict[str, Any]: """노선을 갈아 끼운 뒤의 재계산 — 초기 업로드 체인과 같은 로직을 그대로 탄다. `balanced`(추천 갈래 · 제약)가 오면 그 제약으로 균형 종단을 셈해 1차 종단으로 쓴다(PLAN 74-4). + `planned_lines` = (옛 · 새 계획 폴리라인) — 계획고 옮김의 바뀐 구간을 화면과 같은 선으로 가름. """ import aiomysql @@ -433,6 +444,7 @@ async def _recompute( points, previous_params, target, + planned_lines, ) failure = saved.get("error") marks.append(("재확정 체인", time.perf_counter())) @@ -559,6 +571,7 @@ async def replan_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, An working_path = planned_route_working_path(project_root) keep = (working_path, _curves_path(working_path), _nodes_path(working_path)) previous = {path: path.read_bytes() if path.is_file() else None for path in keep} + old_line = await asyncio.to_thread(_planned_line, project_root) summary = await asyncio.to_thread( functools.partial( _write_planned_polyline, @@ -581,12 +594,18 @@ async def replan_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, An summary["violations"], radius_m, ) + # 균형 종단 새로 풂 = 추천 [적용](`as_initial`)만 — 손으로 고친 노선은 계획고 옮김(344-2). result = await _recompute( project_id, project_root, stored_path, - (request.initial or RouteInitialRequest()) if request.balanced_profile else None, + ( + (request.initial or RouteInitialRequest()) + if request.balanced_profile and as_initial + else None + ), request.terrain, + (old_line, await asyncio.to_thread(_vertices_of, working_path)), ) if "error" in result: # 노선을 되돌린다 — 실패했는데 새 노선만 남으면 화면·정본이 어긋난 채 굳는다. diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index eff95e9b5..224bbfb60 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -589,7 +589,6 @@ export async function mountRouteEditor( terrain: pickedTerrain, stationSnap: () => stationSnap, initial, - profileShown: async () => initial.withProfile(), // 띠 없음(345-5) — 지금 추천 갈래가 정함 onApplied, }); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index 01405b58d..8e19191ef 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -50,8 +50,6 @@ export interface RouteApplyParams { current: () => unknown; keep: () => void; }; - /** 종단 띠 — 응답을 기다려 띠에 보인 계획선(균형 종단 · 지반고)을 그대로 적용한다(PLAN 75-1). */ - profileShown: () => Promise; /** 저장이 끝나면 페이지를 새로고침 없이 다시 세운다. */ onApplied: () => void | Promise; } @@ -92,8 +90,7 @@ export function bindRouteApply(params: RouteApplyParams): void { return { draft: routeDraftOf(nodes, settings()), initial: params.initial.current(), - // 띠 응답 전이면 기다린다 — 서버는 같은 노선 · 같은 셈으로 띠 계획선을 다시 낸다. - balanced_profile: await params.profileShown(), + // 손으로 고친 노선 = 계획고 옮김만(344-2 · 349-0v) — 균형 종단은 추천 [적용](`saveRecommendedRoute`)만. }; }, settings, diff --git a/resources/tester/helper_346_3_route_save.cjs b/resources/tester/helper_346_3_route_save.cjs index 149d689b1..ac42504f0 100644 --- a/resources/tester/helper_346_3_route_save.cjs +++ b/resources/tester/helper_346_3_route_save.cjs @@ -122,7 +122,6 @@ const apply = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Ap terrain: () => "sheet", stationSnap: () => true, initial: { current: () => ({ mode: "follow" }), keep: () => (kept += 1) }, - profileShown: async () => true, onApplied: () => (rebuilt += 1), }); out.unchanged = await apply.pendingRouteSave("p1"); diff --git a/resources/tester/helper_349_0v_span.cjs b/resources/tester/helper_349_0v_span.cjs new file mode 100644 index 000000000..ab579bdd2 --- /dev/null +++ b/resources/tester/helper_349_0v_span.cjs @@ -0,0 +1,46 @@ +/* 349-0v 바뀐 구간 거울 — 화면 planline 단계 차례 그대로: 새 노선 = `buildEditedPolyline`(노드 초안) · + * 옛 노선 = 저장 계획 폴리라인 · `changedSpan` → `carryProfileEdits`. 입력 argv[2] JSON · 출력 JSON. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); + +function loadTs(file, stubs = {}) { + const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (stubs[request]) return stubs[request]; + throw new Error(`stub 없음: ${request} (${file})`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const curve = loadTs("B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts"); +const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts"); +const carry = loadTs("B05_Profile/logic/B05_Profile_Grade_Carry.ts", { + "./B05_Profile_UI_Profile_Alignment": alignment, +}); + +const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const xy = (line) => line.map(([x, y]) => ({ x, y })); +const built = curve.buildEditedPolyline( + input.nodes, + input.curveOn, + input.curveRadius, + input.minRadiusM, +); +const span = carry.changedSpan(xy(input.old_line), xy(built.vertices)); +process.stdout.write( + JSON.stringify({ + new_line: built.vertices, + span, + result: span + ? carry.carryProfileEdits(input.base_pvi, input.edits, span, input.ground_pvi) + : null, + }), +); diff --git a/resources/tester/test_346_3_route_save.py b/resources/tester/test_346_3_route_save.py index 55bf9a055..31624e733 100644 --- a/resources/tester/test_346_3_route_save.py +++ b/resources/tester/test_346_3_route_save.py @@ -455,6 +455,19 @@ def test_추천_종단_반영이면_균형_종단(chain_world, monkeypatch): assert "balanced" in chain_world.order and "profile" not in chain_world.order +def test_계획고_옮김은_계획_폴리라인으로_가름(chain_world, monkeypatch): + seen: list = [] + + async def carry(root, relative, route_id, alignment, old_xy, new_xy): + seen.append((old_xy, new_xy)) + + monkeypatch.setattr(save, "_carry_profile", carry) + _run(chain_world, planned_lines=([[0, 0], [1, 0]], [[0, 0], [2, 0]])) + assert seen == [([[0, 0], [1, 0]], [[0, 0], [2, 0]])] # 화면 planline 과 같은 선(349-0v) + _run(chain_world) + assert seen[-1][1] == [list(p) for p in NEW] # 없으면 풀린 노선 geojson + + # ── 초기값 규칙 ─────────────────────────────────────────────────────────────── @@ -492,6 +505,22 @@ def test_추천_적용은_초기값_다시(api_world): assert result["initial_refreshed"] is True and len(api_world) == 1 +def test_균형_종단은_추천_적용만_손_저장은_계획고_옮김(api_world, monkeypatch): + calls: list = [] + + async def recompute(project_id, root, stored, balanced=None, terrain=None, lines=None): + calls.append((balanced, lines)) + return {"route_id": 9, "total_length_m": 10.0} + + monkeypatch.setattr(replan, "_recompute", recompute) + monkeypatch.setattr(replan, "_planned_line", lambda root: [[0.0, 0.0], [4.0, 4.0]]) + monkeypatch.setattr(replan, "_vertices_of", lambda path: [[0.0, 0.0], [5.0, 5.0]]) + asyncio.run(replan.replan_route(uuid4(), _body(balanced_profile=True))) # 손으로 고친 노선 + asyncio.run(replan.replan_route(uuid4(), _body(balanced_profile=True, as_initial=True))) + assert calls[0] == (None, ([[0.0, 0.0], [4.0, 4.0]], [[0.0, 0.0], [5.0, 5.0]])) + assert calls[1][0] is not None # 추천 [적용] = 균형 종단 그대로 + + def test_초안_칸이_어긋나면_400(api_world): body = save.RouteSaveRequest(draft={"nodes": [[0, 0], [1, 1]], "curveOn": [True]}) response = asyncio.run(replan.replan_route(uuid4(), body)) diff --git a/resources/tester/test_346_3_route_save_screen.py b/resources/tester/test_346_3_route_save_screen.py index 850545e9c..09367553f 100644 --- a/resources/tester/test_346_3_route_save_screen.py +++ b/resources/tester/test_346_3_route_save_screen.py @@ -57,7 +57,8 @@ def test_초안은_계약_RouteDraft_꼴(out): ) assert draft["curveOn"] == [True, False, True] and draft["curveRadius"] == [None, 25, None] assert draft["stationSnap"] is True and draft["terrain"] == "sheet" - assert out["body"]["initial"] == {"mode": "follow"} and out["body"]["balanced_profile"] is True + assert out["body"]["initial"] == {"mode": "follow"} + assert "balanced_profile" not in out["body"] # 손으로 고친 노선 = 계획고 옮김만(349-0v) assert "as_initial" not in out["body"] # 손으로 고친 노선 = 초기값 그대로 diff --git a/resources/tester/test_349_0v_route_save_span.py b/resources/tester/test_349_0v_route_save_span.py new file mode 100644 index 000000000..5f0e421c8 --- /dev/null +++ b/resources/tester/test_349_0v_route_save_span.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +"""349-0v 손으로 고친 노선 [저장] — 미리보기 계획고 = 저장 뒤 계획고(바뀐 구간 거울). + +화면 planline 단계: 옛 = 저장 계획 폴리라인(`read_route_plan` planned) · 새 = `buildEditedPolyline`(노드 +초안) → `changedSpan` → `carryProfileEdits`. 서버 노선 저장: 옛 = 같은 계획 폴리라인 CSV · 새 = +`_write_planned_polyline` 이 쓴 CSV → `changed_span` → `carry_profile_edits`. 두 길이 같은 구간 · 같은 +계획고를 내야 함(풀린 노선 geojson 으로 가르면 점 간격이 달라 앞머리가 일찍 갈렸음). +""" + +from __future__ import annotations + +import json +import math +import subprocess +import sys +from pathlib import Path + +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 import B05_Profile_Router_Replan as replan # noqa: E402 +from B05_Profile.B05_Profile_Engine_Grade_Carry import ( # noqa: E402 + carry_profile_edits, + changed_span, +) + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + +RADIUS_M = 30.0 +OLD_NODES = [(0.0, 0.0), (180.0, 20.0), (320.0, 140.0), (480.0, 130.0), (620.0, 260.0)] +OLD_NODES += [(800.0, 250.0), (950.0, 380.0)] +MOVED = 4 # 가운데 뒤쪽 꺾임점 하나를 끎(검증_sub2c 끌기와 같은 꼴) +NEW_NODES = [(x + 25.0, y - 30.0) if i == MOVED else (x, y) for i, (x, y) in enumerate(OLD_NODES)] +CURVE_ON = [True] * len(OLD_NODES) +RADII: list[float | None] = [None] * len(OLD_NODES) + + +def _csv_line(path: Path, nodes) -> list[list[float]]: + replan._write_planned_polyline( + path, list(nodes), RADIUS_M, simplify=False, curve_flags=CURVE_ON, radii=RADII + ) + return replan._vertices_of(path) + + +def _length(line) -> float: + return sum(math.dist(a, b) for a, b in zip(line, line[1:])) + + +@pytest.fixture(scope="module") +def world(tmp_path_factory): + tmp = tmp_path_factory.mktemp("span") + old_line = _csv_line(tmp / "old.csv", OLD_NODES) # 저장 노선(화면 · 서버 같은 파일) + new_line = _csv_line(tmp / "new.csv", NEW_NODES) # 서버가 [저장] 때 쓰는 새 계획 폴리라인 + total_old, total_new = _length(old_line), _length(new_line) + base_pvi = [ + {"chainage_m": round(s, 6), "elevation_m": 100.0 + 0.04 * s} + for s in [0.0, 150.0, 300.0, 450.0, 600.0, 750.0, 900.0, total_old] + ] + ground_pvi = [ + {"chainage_m": round(s, 6), "elevation_m": 101.0 + 0.03 * s} + for s in [x * 40.0 for x in range(int(total_new // 40) + 1)] + [total_new] + ] + edits = { + "station_offsets": {"150.000": 0.8, "600.000": -0.5, "900.000": 0.4}, + "curve_radii": {"300.000": 400.0}, + } + payload = { + "nodes": [list(p) for p in NEW_NODES], + "curveOn": CURVE_ON, + "curveRadius": RADII, + "minRadiusM": RADIUS_M, + "old_line": old_line, + "base_pvi": base_pvi, + "edits": edits, + "ground_pvi": ground_pvi, + } + source = tmp / "input.json" + source.write_text(json.dumps(payload), encoding="utf-8") + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_349_0v_span.cjs"), str(source)], + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return {**payload, "new_line_py": new_line, "ts": json.loads(proc.stdout)} + + +def test_새_계획_폴리라인_화면_서버_같음(world): + ts_line = world["ts"]["new_line"] + assert len(ts_line) == len(world["new_line_py"]) + assert max(math.dist(a, b) for a, b in zip(ts_line, world["new_line_py"])) < 1e-3 + + +def test_바뀐_구간_화면_서버_같음(world): + span = changed_span( + [tuple(p) for p in world["old_line"]], [tuple(p) for p in world["new_line_py"]] + ) + ts_span = world["ts"]["span"] + assert span is not None and ts_span is not None + for key in ("start_m", "old_end_m", "new_end_m", "delta_m"): + assert span[key] == pytest.approx(ts_span[key], abs=1e-3), key + assert (span["start_fixed"], span["end_fixed"]) == ( + ts_span["start_fixed"], + ts_span["end_fixed"], + ) + # 끈 꺾임점 앞앞 꺾임점의 곡선 끝(마지막 같은 점)부터 — 노선 앞머리부터 다시 풀지 않음 + assert _length(OLD_NODES[: MOVED - 1]) < span["start_m"] < _length(OLD_NODES[:MOVED]) + + +def test_옮긴_계획고_화면_서버_같음(world): + span = changed_span( + [tuple(p) for p in world["old_line"]], [tuple(p) for p in world["new_line_py"]] + ) + base, edits = carry_profile_edits(world["base_pvi"], world["edits"], span, world["ground_pvi"]) + ts = world["ts"]["result"] + flat = [v for n in base for v in (n["chainage_m"], n["elevation_m"])] + assert flat == pytest.approx( + [v for n in ts["basePvi"] for v in (n["chainage_m"], n["elevation_m"])], abs=1e-3 + ) # 서버 새 선 = CSV 0.1 mm 반올림 · 화면 = 원값 + assert edits == ts["edits"] + assert edits["station_offsets"]["150.000"] == 0.8 # 바뀐 구간 앞 사용자 계획고 그대로 diff --git a/resources/tester/test_75_profile_strip.py b/resources/tester/test_75_profile_strip.py index 1880ed6cd..6502cdd71 100644 --- a/resources/tester/test_75_profile_strip.py +++ b/resources/tester/test_75_profile_strip.py @@ -24,8 +24,8 @@ def test_띠_없음_화면_요청_서버_길(): assert "routeedit__strip" not in _read("B05_Profile_UI_Style_RouteEdit.css") editor = _read("B05_Profile_UI_RouteEdit.ts") assert "ProfileStrip" not in editor and "strip." not in editor - # [노선 반영]의 균형 종단 여부 = 지금 추천 갈래(띠가 보이던 값과 같은 규칙) - assert "profileShown: async () => initial.withProfile()," in editor + # 손으로 고친 노선 [저장] = 균형 종단 안 보냄(349-0v) — 추천 [적용]만 + assert "profileShown" not in editor assert "profile-preview" not in _read("B05_Profile_Api_Replan.ts") assert "is-tick" not in _read("B05_Profile_UI_RouteEdit_Recommend_Profile.ts") assert not hasattr(initial, "profile_preview") and not hasattr(initial, "ProfilePreviewRequest")