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:
@@ -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()))
|
||||
|
||||
|
||||
@@ -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:
|
||||
# 노선을 되돌린다 — 실패했는데 새 노선만 남으면 화면·정본이 어긋난 채 굳는다.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
@@ -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 # 바뀐 구간 앞 사용자 계획고 그대로
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user