From 7dbae140c176b87e9fec51e26f30413101aae9a9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 10:02:00 +0900 Subject: [PATCH 1/2] =?UTF-8?q?test(B05=C2=B7B06):=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=99=95=EB=B3=B5=20=E2=80=94=20=EC=A7=80=EB=8F=84=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=81=88=20=EA=B4=80=EC=9D=80=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=B2=98=EB=9F=BC=20=EC=86=8D=EC=84=B1=EC=9D=84=20=EC=83=88=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=EC=9C=BC=EB=A1=9C=20=EC=98=AE=EA=B8=B4=20?= =?UTF-8?q?=EB=92=A4=20=EB=90=98=EB=B6=99=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sub5 고침(끌기 onMove → 시설 보관소 move)을 받은 뒤 본문 만들기도 같은 차례로 - sub5 · sub6 받은 뒤 남은 차이: 관 측점 정수 m 격자 붙이기 4 곳(440.24 · 619.71 · 720.44 · 899.91) · B06 저장 차례에서 옮긴 관 측점 횡단 patch 가 받을 행이 없어 버려짐 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AeeQRLknEpP4SFjVkjDWDs --- resources/tester/helper_b05_save_payload.mjs | 4 +++- resources/tester/test_b05_b06_save_roundtrip.py | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/resources/tester/helper_b05_save_payload.mjs b/resources/tester/helper_b05_save_payload.mjs index 363a51f3..08d24b21 100644 --- a/resources/tester/helper_b05_save_payload.mjs +++ b/resources/tester/helper_b05_save_payload.mjs @@ -1,6 +1,6 @@ // 저장 왕복 시험용 — 화면 TS 를 node 로 묶어 저장 본문을 만든다. // node helper_b05_save_payload.mjs <저장소 뿌리> · 표준입력 = 작업 목록(JSON) · 표준출력 끝 줄 = 결과 목록 -// {op:"attach", entries:[관 응답], points:[{chainage_m, source}]} → attach 결과(DetailPipeInput[]) +// {op:"attach", entries:[관 응답], moves:[[옛, 새]], points:[{chainage_m, source}]} → attach 결과 // {op:"station", chainage, interval} → 구조물 측점 칸에 쓰고 다시 읽은 값 // {op:"culvert", server:[관 응답], pending:{측점:조각}, moves:{측점:새 측점}} → PUT 본문 points 또는 null import fs from "node:fs"; @@ -78,6 +78,8 @@ try { if (job.op === "attach") { const store = mod.createFacilityStore(); store.replaceFromResponse(job.entries); + // 지도에서 끈 관 — 화면이 끌기를 마치면 속성을 새 측점으로 옮김(onMove) + for (const [from, to] of job.moves ?? []) store.move(from, to); results.push(store.attach(job.points)); } else if (job.op === "station") { const fields = mod.stationFields("측점", () => job.interval); diff --git a/resources/tester/test_b05_b06_save_roundtrip.py b/resources/tester/test_b05_b06_save_roundtrip.py index 4132a008..a3b25de8 100644 --- a/resources/tester/test_b05_b06_save_roundtrip.py +++ b/resources/tester/test_b05_b06_save_roundtrip.py @@ -301,7 +301,8 @@ class Flow: } for p in self.want_pipes ] - (attached,) = _screen([{"op": "attach", "entries": entries, "points": points}]) + job = {"op": "attach", "entries": entries, "moves": [self.pipe_move], "points": points} + (attached,) = _screen([job]) return attached def b06_pipe_payload(self) -> list[dict[str, Any]]: From 3b0c5480f2755aaca6011d1bb74af99339e79045 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 10:02:14 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(B05):=20=EC=B4=88=EA=B8=B0=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=EA=B0=88=EB=9E=98=20logic=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EB=B0=9B=EC=9D=8C=20=C2=B7=20=ED=98=84=EC=9E=AC=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=EA=B0=92=EC=9D=84=20[=ED=99=95=EC=9D=B8]=20=EB=95=8C?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=EC=97=90=20=EC=A0=81?= =?UTF-8?q?=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /route/initial 이 logic(또는 mode) 을 받음 · 응답에 logic 칸 - POST /route/replan 의 initial {logic, weights} 을 성공 때만 initial_choice.json 에 적음(자동저장 없음) - GET /route/plan · POST /route/initial · POST /route/replan 응답에 current_initial Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF --- .../B05_Profile_Engine_RouteInitial.py | 27 ++++++++++++++ B05_Profile/B05_Profile_Router_Replan.py | 36 ++++++++++++++++--- resources/tester/test_route_initial.py | 23 ++++++++++++ 3 files changed, 82 insertions(+), 4 deletions(-) diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial.py b/B05_Profile/B05_Profile_Engine_RouteInitial.py index 8c5cd017..44ac532e 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial.py @@ -27,6 +27,7 @@ from __future__ import annotations +import json import math from dataclasses import dataclass from pathlib import Path @@ -133,6 +134,31 @@ def load_terrain_and_streams( return Terrain(np.asarray(x), np.asarray(y), np.asarray(z), np.asarray(valid)), streams +def _choice_path(project_root: Path) -> Path: + return project_root / "B05_Profile" / "route" / "initial_choice.json" + + +def read_initial_choice(project_root: Path) -> dict[str, Any]: + """현재 초기값(갈래 · 비중) — [확인] 때 적어 둔 것. 없거나 못 읽으면 추종.""" + try: + data = json.loads(_choice_path(project_root).read_text(encoding="utf-8")) + logic = data["logic"] if data.get("logic") in MODES else "follow" + return {"logic": logic, "weights": resolve_weights(logic, data.get("weights"))} + except (OSError, ValueError, KeyError, TypeError, AttributeError): + return {"logic": "follow", "weights": resolve_weights("follow", None)} + + +def write_initial_choice( + project_root: Path, logic: str, weights: dict[str, float] | None +) -> dict[str, Any]: + """[확인] 때만 부름 — 작업본 한 칸. 다시 열기 · 다른 PC 에서도 같은 초기값.""" + choice = {"logic": logic, "weights": resolve_weights(logic, weights)} + path = _choice_path(project_root) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(choice, ensure_ascii=False), encoding="utf-8") + return choice + + def resolve_weights(mode: str, weights: dict[str, float] | None) -> dict[str, float]: """갈래의 비중(합 100). weighted 가 아니면 갈래가 정한 값.""" if mode != "weighted": @@ -192,6 +218,7 @@ def generate_initial_route( ) return { "mode": mode, + "logic": mode, "weights": used, "planned": [[round(x, 4), round(y, 4)] for x, y in outline.vertices], "nodes": [node.as_dict() for node in outline.nodes], diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 0297f3b3..4ad1d17e 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -41,9 +41,10 @@ from uuid import UUID from fastapi import APIRouter from fastapi.responses import JSONResponse -from pydantic import BaseModel, Field +from pydantic import AliasChoices, BaseModel, Field from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice, write_initial_choice from common_util.common_util_initial_snapshot import design_route_csv_path from common_util.common_util_route_geometry import ( densify_route, @@ -96,7 +97,9 @@ class RouteInitialWeights(BaseModel): class RouteInitialRequest(BaseModel): """초기 계획노선 갈래 — follow(예상노선 추종) · earthwork · gentle · weighted.""" - mode: Literal["follow", "earthwork", "gentle", "weighted"] = "follow" + mode: Literal["follow", "earthwork", "gentle", "weighted"] = Field( + "follow", validation_alias=AliasChoices("logic", "mode") + ) weights: RouteInitialWeights | None = None @@ -104,6 +107,8 @@ class RouteReplanRequest(BaseModel): """고친 계획노선. 정점은 시점 → 종점 순서(사업지 좌표계 m).""" vertices: list[RouteVertexInput] = Field(default_factory=list) + initial: RouteInitialRequest | None = None + """이 노드를 낳은 초기값(갈래 · 비중) — 성공하면 프로젝트에 적어 둔다(현재 초기값).""" async def _project_paths(project_id: UUID) -> tuple[Path, str] | None: @@ -496,6 +501,7 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse: "curve_count": len(saved_curves) if saved_curves else outline.curve_count, "violation_count": outline.violation_count, "edited": bool(working), + "current_initial": await asyncio.to_thread(read_initial_choice, project_root), } @@ -561,7 +567,23 @@ async def replan_route( return JSONResponse( status_code=409, content={"status": "error", "message": result["error"]} ) - return {"status": "success", "project_id": str(project_id), **result} + choice = request.initial + current = ( + await asyncio.to_thread( + write_initial_choice, + project_root, + choice.mode, + choice.weights.model_dump() if choice.weights else None, + ) + if choice + else await asyncio.to_thread(read_initial_choice, project_root) + ) + return { + "status": "success", + "project_id": str(project_id), + **result, + "current_initial": current, + } @router.post("/{project_id}/route/replan/reset", response_model=None) @@ -656,4 +678,10 @@ async def initial_route( result["metrics"]["length_m"], result["metrics"]["offset_avg_m"], ) - return {"status": "success", "project_id": str(project_id), **result} + current = await asyncio.to_thread(read_initial_choice, project_root) + return { + "status": "success", + "project_id": str(project_id), + **result, + "current_initial": current, + } diff --git a/resources/tester/test_route_initial.py b/resources/tester/test_route_initial.py index bf3b850d..b75fec21 100644 --- a/resources/tester/test_route_initial.py +++ b/resources/tester/test_route_initial.py @@ -13,8 +13,10 @@ from B05_Profile.B05_Profile_Engine_RouteInitial import ( Terrain, generate_initial_route, grade_line, + read_initial_choice, resolve_weights, stream_lines, + write_initial_choice, ) GRADE = 0.14 @@ -124,3 +126,24 @@ def test_weights_and_stream_filter() -> None: def test_unknown_mode_rejected(hill: Terrain) -> None: with pytest.raises(ValueError): _run(hill, "shortest") + + +def test_initial_choice_roundtrip(tmp_path) -> None: + """[확인] 때 적은 현재 초기값을 다시 읽음 · 없거나 깨지면 추종.""" + assert read_initial_choice(tmp_path)["logic"] == "follow" + write_initial_choice(tmp_path, "weighted", {"expected": 60, "earthwork": 20, "grade": 20}) + assert read_initial_choice(tmp_path) == { + "logic": "weighted", + "weights": {"expected": 60.0, "earthwork": 20.0, "grade": 20.0}, + } + (tmp_path / "B05_Profile" / "route" / "initial_choice.json").write_text("{", encoding="utf-8") + assert read_initial_choice(tmp_path)["logic"] == "follow" + + +def test_request_accepts_logic_name() -> None: + from B05_Profile.B05_Profile_Router_Replan import RouteInitialRequest, RouteReplanRequest + + assert RouteInitialRequest.model_validate({"logic": "gentle"}).mode == "gentle" + assert RouteInitialRequest.model_validate({"mode": "earthwork"}).mode == "earthwork" + replan = RouteReplanRequest.model_validate({"vertices": [], "initial": {"logic": "weighted"}}) + assert replan.initial is not None and replan.initial.mode == "weighted"