fix(349-0v): 손으로 고친 노선 [저장] = 계획고 옮김만 · 바뀐 구간 = 화면과 같은 계획 폴리라인

손 편집 [저장]이 추천 갈래를 따라 균형 종단을 새로 풀어 사용자 계획고가 노선 전체에서 바뀌던 흠.
화면은 balanced_profile 안 보냄(profileShown 없앰) · 서버도 균형 종단은 as_initial(추천 [적용])일
때만. 계획고 옮김의 바뀐 구간 = 옛 계획 폴리라인(read_route_plan planned) 대 새로 쓴 계획
폴리라인 — 풀린 노선 geojson(점 간격 다름) 대신 화면 planline 단계와 같은 선. 거울 시험 test_349_0v.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
2026-10-16 18:02:58 +09:00
co-authored by Claude Opus 5.5
parent 25eea13ab2
commit c95266efd0
10 changed files with 238 additions and 15 deletions
+9 -5
View File
@@ -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()))
+20 -1
View File
@@ -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:
# 노선을 되돌린다 — 실패했는데 새 노선만 남으면 화면·정본이 어긋난 채 굳는다.
-1
View File
@@ -589,7 +589,6 @@ export async function mountRouteEditor(
terrain: pickedTerrain,
stationSnap: () => stationSnap,
initial,
profileShown: async () => initial.withProfile(), // 띠 없음(345-5) — 지금 추천 갈래가 정함
onApplied,
});
@@ -50,8 +50,6 @@ export interface RouteApplyParams {
current: () => unknown;
keep: () => void;
};
/** 종단 띠 — 응답을 기다려 띠에 보인 계획선(균형 종단 · 지반고)을 그대로 적용한다(PLAN 75-1). */
profileShown: () => Promise<boolean>;
/** 저장이 끝나면 페이지를 새로고침 없이 다시 세운다. */
onApplied: () => void | Promise<void>;
}
@@ -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,
@@ -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");
+46
View File
@@ -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,
}),
);
+29
View File
@@ -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))
@@ -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"] # 손으로 고친 노선 = 초기값 그대로
@@ -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 # 바뀐 구간 앞 사용자 계획고 그대로
+2 -2
View File
@@ -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")