From 8e6a1e27ebde5093658d80a9d572423c7a15b610 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 18:00:34 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EA=B3=84=ED=9A=8D=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=EB=91=90=20=EB=B2=8C=20+=20=EB=85=B8=EC=84=A0=20?= =?UTF-8?q?=EA=B0=88=EC=95=84=20=EB=81=BC=EC=9A=B0=EA=B8=B0=C2=B7=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EC=84=9C=EB=B2=84=20=EA=B2=BD=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 노선을 예상노선(원본)·계획노선(수정본) 두 벌로 나눔 (PLAN 0-7 사용자 확정). - 예상노선 정본 `B05_Profile/route/expected_route.csv` 신설 — 자동 체인이 한 번 씀. 초기값 스냅샷 안에도 같은 CSV 가 있으나 그 폴더는 재확정 체인이 지우므로 스냅샷 밖에 한 벌 둠. - 계획노선 수정본 `B05_Profile/route/planned_route.csv` — 설계 계통(`load_design_route`)이 수정본 → 예상노선 → 스냅샷 순으로 읽음. 노선 초기화는 수정본을 지우는 것. - `GET /route/plan` 두 노선 정점 반환(모달이 점선·실선으로 그림). - `POST /route/replan` 고친 노선을 수정본에 쓰고(조밀화) 재확정 체인 재사용 — 배수유역 다시 → 관 새로 → 계획선·종횡단 재생성 → 옛 측점 설계 누가거리 이월. - `POST /route/replan/reset` 수정본 삭제 후 같은 재계산. - 횡단배수 지점은 노선이 바뀌면 저장분을 버리고 새로 계산(사용자 확정). 구조물은 프로젝트 정본이라 옛 측점값 그대로 남음. Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_Service_Chain.py | 10 + B05_Profile/B05_Profile_Router_Replan.py | 301 +++++++++++++++++++ common_util/common_util_route_geometry.py | 44 ++- main.py | 2 + 4 files changed, 355 insertions(+), 2 deletions(-) create mode 100644 B05_Profile/B05_Profile_Router_Replan.py diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index 00e959af..14127036 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -148,6 +148,10 @@ async def run_auto_design_chain( mark_designing, save_initial_snapshot, ) + from common_util.common_util_route_geometry import ( + expected_route_csv_path, + write_route_csv, + ) from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import get_surface_confirmation_params from config.config_db import get_db_pool @@ -195,6 +199,12 @@ async def run_auto_design_chain( mark_design_failed(project_root, "계획노선이 없어 초기 노선을 세울 수 없습니다.") return None + # 2.5) 예상노선(원본) 정본을 남긴다 — 초기값 스냅샷과 달리 재확정 체인이 지우지 + # 않는 자리라, 노선 초기화가 언제나 이 값으로 돌아갈 수 있다(2026-09-06 PLAN 0-7). + expected_path = expected_route_csv_path(project_root) + if not expected_path.is_file(): + write_route_csv(expected_path, points) + # 3) B05 경로 계산 request = RouteSolveRequest( filter_key=str(defaults["source_filter"]), diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py new file mode 100644 index 00000000..12d7d330 --- /dev/null +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -0,0 +1,301 @@ +"""계획노선 편집 — 노선 두 벌(예상노선·계획노선)과 그 뒤 재계산의 서버 몫. + +노선은 두 벌이다(2026-09-06 사용자 확정, PLAN 0-7). + + - **예상노선**(원본) — 파일 업로드 자동 체인이 낸 노선. `initial_snapshot/planned_route.csv`. + 어떤 경로로도 고치지 않는다. + - **계획노선**(수정본) — 예상노선과 **같은 값으로 시작**해 사용자가 고쳐 쓰는 노선. + `B05_Profile/route/planned_route.csv`. 설계 계통은 이것이 있으면 이것을 읽는다 + (`load_design_route`). + +노선 초기화는 수정본 파일을 지우는 것이다 — 그러면 원본을 읽으므로 「원본을 수정본으로 +복사」와 결과가 같다. + +[확인]을 눌렀을 때만 계산이 돈다. 재계산은 **초기 업로드 체인의 로직을 그대로 재사용**한다 +(2026-09-06 사용자 제안) — `run_redesign_chain`이 배수유역 다시 분석 → 기본 관 저장 → 관 +정착 계획선 재산출 → 종횡단 재생성 → 옛 측점 설계를 누가거리로 이월 → B05·B06 확정까지 한 +줄로 돈다. 여기서 하는 일은 그 앞에 **노선을 갈아 끼우는 것**뿐이다. + +시설 처리(2026-09-06 사용자 확정) — 횡단배수 지점은 노선 자리에 따라 생기고 없어지므로 +저장분을 버리고 **새로 계산한 값**을 쓴다. 그 밖의 구조물은 **옛 측점값을 그대로** 이어받는다 +(`structures.json`은 프로젝트 정본이라 손대지 않으면 그대로 남는다). + + GET /api/projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계) + POST /api/projects/{id}/route/replan → 고친 계획노선으로 갈아 끼우고 재계산 + POST /api/projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산 +""" + +import asyncio +import csv +import logging +from pathlib import Path +from typing import Any +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from common_util.common_util_initial_snapshot import design_route_csv_path +from common_util.common_util_route_geometry import ( + densify_route, + expected_route_csv_path, + planned_route_working_path, + read_planned_route_csv, + write_route_csv, +) +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import get_db_pool +from config.config_system import ( + ROUTE_DIRECT_LINK_CELL_FACTOR, + ROUTE_GRID_RES_M, + ROUTE_PLANNED_DENSIFY_SAFETY, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B05 Route Design"]) + +_PROJECT_PATH_MISSING = {"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."} + + +class RouteVertexInput(BaseModel): + """계획노선 정점 하나 — 사업지 좌표계(m).""" + + x: float + y: float + + +class RouteReplanRequest(BaseModel): + """고친 계획노선. 정점은 시점 → 종점 순서.""" + + vertices: list[RouteVertexInput] = Field(default_factory=list) + + +async def _project_paths(project_id: UUID) -> tuple[Path, str] | None: + """(프로젝트 실경로, 저장소 상대경로). 없으면 None.""" + pool = get_db_pool() + async with pool.acquire() as connection: + stored = await get_project_storage_relative_path(connection, project_id) + if not stored: + return None + return Path(resolve_stored_project_path(stored)), str(stored) + + +def _vertices_of(path: Path) -> list[list[float]]: + """CSV 정점을 [[x, y], …]로. 없거나 못 읽으면 빈 목록.""" + if not path.is_file(): + return [] + route = read_planned_route_csv(path) + if route is None: + return [] + return [[float(vertex.x), float(vertex.y)] for vertex in route.vertices] + + +def _ensure_expected_route(project_root: Path) -> str: + """예상노선(원본) 정본이 없으면 만들어 둔다. 어디서 씨앗을 얻었는지 돌려준다. + + 새 프로젝트는 자동 체인이 만들어 두지만(2026-09-06), 그 전에 만들어진 프로젝트는 + 초기값 스냅샷 안에만 있거나 그마저 재확정 체인이 지운 뒤일 수 있다. 노선 초기화가 + 성립하려면 이 파일이 반드시 있어야 하므로 여기서 한 번 세워 둔다. + """ + target = expected_route_csv_path(project_root) + if target.is_file(): + return "already" + for source, label in ( + (design_route_csv_path(project_root), "snapshot"), + (planned_route_working_path(project_root), "working"), + ): + vertices = _vertices_of(source) + if len(vertices) >= 2: + write_route_csv(target, [{"x": x, "y": y} for x, y in vertices]) + logger.info("예상노선 정본을 %s 에서 세웠습니다: %s", label, target) + return label + return "none" + + +def _write_working_route(path: Path, vertices: list[tuple[float, float]]) -> int: + """계획노선(수정본) CSV를 쓴다. 열 이름은 `read_planned_route_csv()`가 아는 것. + + 쓰기 전에 **조밀화**한다 — 정점이 성기면 B05 격자 탐색이 원좌표를 그대로 잇지 못하고 + 제 나름의 길을 찾아 사용자가 그린 선과 달라진다(`densify_route` 주석). 평면 형상은 + 바뀌지 않고 같은 선 위에 점만 더 찍힌다. + """ + dense = densify_route( + vertices, + ROUTE_DIRECT_LINK_CELL_FACTOR * ROUTE_GRID_RES_M * ROUTE_PLANNED_DENSIFY_SAFETY, + ) + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="") as file: + writer = csv.writer(file) + writer.writerow(("sequence", "x", "y")) + writer.writerows((index, round(x, 4), round(y, 4)) for index, (x, y) in enumerate(dense)) + return len(dense) + + +async def _recompute(project_id: UUID, project_root: Path, stored_path: str) -> dict[str, Any]: + """노선을 갈아 끼운 뒤의 재계산 — 초기 업로드 체인과 같은 로직을 그대로 탄다.""" + import aiomysql + + from B03_FileInput.B03_FileInput_Service_Chain import ( + _planned_route_points_in_project_crs, + run_redesign_chain, + ) + from B04_PreProcess.B04_PreProcess_Service import find_surface_model_for_selection + from B05_Profile.B05_Profile_Repository import get_latest_route + from common_util.common_util_drainage_pipes import clear_pipe_points + from common_util.common_util_surface_confirmation import get_surface_confirmation_params + from common_util.common_util_workflow_state import get_workflow_state, start_stage + + # 횡단배수 지점은 새 노선에서 새로 계산한다 — 저장분을 남기면 옛 자리가 살아난다. + await asyncio.to_thread(clear_pipe_points, stored_path) + + pool = get_db_pool() + async with pool.acquire() as connection: + selection = await get_surface_confirmation_params(connection, str(project_id)) + latest = await get_latest_route(connection, project_id) + # 지금 노선이 쓰던 지표면 모델이 1순위다 — 확정 선택값으로 다시 찾으면 프로젝트마다 + # 모델 조합이 달라 못 찾는 경우가 있다(용화: filter=classification/method=dtm 무매칭). + surface_model_id = ( + int(latest["surface_model_id"]) if latest and latest.get("surface_model_id") else None + ) + if surface_model_id is None: + try: + surface_model_id = await find_surface_model_for_selection( + connection, project_id, selection + ) + except Exception: + surface_model_id = None + async with connection.cursor() as cursor: + await cursor.execute( + "SELECT route_start_m, route_end_m FROM projects WHERE id = %s", + (str(project_id),), + ) + range_row = await cursor.fetchone() + if surface_model_id is None: + return {"error": "확정된 지표면 모델을 찾지 못해 노선을 다시 계산할 수 없습니다."} + route_range = (range_row[0], range_row[1]) if range_row else None + + # 갈아 끼운 노선을 stage 2 제어점으로 세운다 — 재확정 체인이 여기서 BP·EP·경유점을 + # 읽어 경로를 다시 푼다. 읽기는 설계 계통과 같은 한 곳(`load_design_route`)을 지난다. + points = await asyncio.to_thread( + _planned_route_points_in_project_crs, project_root, selection, route_range + ) + if not points or len(points) < 2: + return {"error": "계획노선을 읽지 못했습니다(정점이 2개 미만)."} + + # 수정본도 예상노선 정본도 없던 프로젝트 — 지금 읽은 것이 곧 예상노선이므로 여기서 세운다. + expected_path = expected_route_csv_path(project_root) + if not expected_path.is_file() and not planned_route_working_path(project_root).is_file(): + await asyncio.to_thread(write_route_csv, expected_path, points) + logger.info("예상노선 정본을 원본 재판독으로 세웠습니다: %s", expected_path) + + pool = get_db_pool() + async with pool.acquire() as connection: + async with connection.cursor(aiomysql.DictCursor) as cursor: + state = await get_workflow_state(cursor, str(project_id)) + stage2 = next( + (s for s in (state or {}).get("stages", []) if int(s.get("stage_no", -1)) == 2), None + ) + params = dict((stage2 or {}).get("params") or {}) + params["points"] = { + "bp": points[0], + "ep": points[-1], + "cp": [{**point, "order": index} for index, point in enumerate(points[1:-1], start=1)], + } + async with connection.cursor(aiomysql.DictCursor) as cursor: + await start_stage(cursor, str(project_id), 2, params) + await connection.commit() + + await run_redesign_chain(project_id, int(surface_model_id), selection) + + pool = get_db_pool() + async with pool.acquire() as connection: + made = await get_latest_route(connection, project_id) + return { + "route_id": int(made["id"]) if made else None, + "total_length_m": float(made.get("total_length_m") or 0.0) if made else None, + "vertex_count": len(points), + } + + +@router.get("/{project_id}/route/plan", response_model=None) +async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse: + """예상노선(원본)과 계획노선(수정본) 정점을 함께 돌려준다 — 편집 모달이 둘 다 그린다.""" + paths = await _project_paths(project_id) + if paths is None: + return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) + project_root, _ = paths + # 예상노선 정본이 먼저, 없으면 옛 자리(초기값 스냅샷)를 본다. + expected = await asyncio.to_thread(_vertices_of, expected_route_csv_path(project_root)) + if not expected: + expected = await asyncio.to_thread(_vertices_of, design_route_csv_path(project_root)) + working_path = planned_route_working_path(project_root) + planned = await asyncio.to_thread(_vertices_of, working_path) + return { + "status": "success", + "project_id": str(project_id), + "expected": expected, + # 고친 적이 없으면 계획노선 = 예상노선(같은 값으로 시작한다). + "planned": planned or expected, + "edited": bool(planned), + } + + +@router.post("/{project_id}/route/replan", response_model=None) +async def replan_route( + project_id: UUID, request: RouteReplanRequest +) -> dict[str, Any] | JSONResponse: + """고친 계획노선으로 갈아 끼우고 배수유역부터 다시 계산한다(모달 [확인]).""" + if len(request.vertices) < 2: + return JSONResponse( + status_code=400, + content={"status": "error", "message": "계획노선은 정점이 2개 이상이어야 합니다."}, + ) + paths = await _project_paths(project_id) + if paths is None: + return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) + project_root, stored_path = paths + + # 고치기 전에 예상노선(원본)이 서 있는지 본다 — 초기화가 돌아갈 자리다. + await asyncio.to_thread(_ensure_expected_route, project_root) + written = await asyncio.to_thread( + _write_working_route, + planned_route_working_path(project_root), + [(vertex.x, vertex.y) for vertex in request.vertices], + ) + logger.info( + "계획노선 갈아 끼움: project_id=%s 정점 %d→%d(조밀화)", + project_id, + len(request.vertices), + written, + ) + result = await _recompute(project_id, project_root, stored_path) + if "error" in result: + return JSONResponse( + status_code=409, content={"status": "error", "message": result["error"]} + ) + return {"status": "success", "project_id": str(project_id), **result} + + +@router.post("/{project_id}/route/replan/reset", response_model=None) +async def reset_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse: + """계획노선을 예상노선으로 되돌리고 다시 계산한다(노선 초기화). + + 수정본 파일을 지우면 설계 계통이 원본을 읽으므로 「원본을 수정본으로 복사」와 같다. + """ + paths = await _project_paths(project_id) + if paths is None: + return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) + project_root, stored_path = paths + await asyncio.to_thread(_ensure_expected_route, project_root) + working_path = planned_route_working_path(project_root) + if working_path.is_file(): + working_path.unlink() + logger.info("계획노선 초기화(예상노선으로): project_id=%s", project_id) + result = await _recompute(project_id, project_root, stored_path) + if "error" in result: + return JSONResponse( + status_code=409, content={"status": "error", "message": result["error"]} + ) + return {"status": "success", "project_id": str(project_id), **result} diff --git a/common_util/common_util_route_geometry.py b/common_util/common_util_route_geometry.py index aa1a297c..35c707cd 100644 --- a/common_util/common_util_route_geometry.py +++ b/common_util/common_util_route_geometry.py @@ -212,6 +212,40 @@ def find_planned_route_file(input_dir: Path) -> Path | None: return None +def expected_route_csv_path(project_root: Path) -> Path: + """예상노선(원본) CSV 자리 — 자동 체인이 낸 노선을 **그대로** 보관한다. + + 초기값 스냅샷(`initial_snapshot/`) 안에도 같은 CSV가 있지만 그 폴더는 재확정 체인이 + 통째로 지운다(`discard_initial_snapshot`). 노선 초기화는 언제나 예상노선으로 돌아갈 수 + 있어야 하므로 스냅샷 **밖**에 한 벌 둔다(2026-09-06). + """ + return Path(project_root) / "B05_Profile" / "route" / "expected_route.csv" + + +def write_route_csv(path: Path, points: list[dict[str, float]]) -> None: + """노선 정점을 CSV로 적는다. 열 이름은 `read_planned_route_csv()`가 아는 것.""" + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="") as file: + writer = csv.writer(file) + writer.writerow(("sequence", "x", "y")) + writer.writerows( + (index, round(float(point["x"]), 4), round(float(point["y"]), 4)) + for index, point in enumerate(points) + ) + + +def planned_route_working_path(project_root: Path) -> Path: + """계획노선(수정본) CSV 자리 — 사용자가 노선을 고치면 여기에 쓴다. + + 노선은 두 벌이다(2026-09-06 사용자 확정): **예상노선**(원본, + `initial_snapshot/planned_route.csv`)은 자동 체인이 한 번 쓰고 안 바뀌며, + **계획노선**(수정본)은 예상노선과 같은 값으로 시작해 사용자가 고쳐 쓴다. + 설계 계통은 수정본이 있으면 그것을 읽는다 — 노선 초기화는 이 파일을 지우는 것이며, + 그것이 곧 「원본을 수정본으로 복사」와 같다. + """ + return Path(project_root) / "B05_Profile" / "route" / "planned_route.csv" + + def load_design_route( project_root: Path, surface_params: dict[str, Any] | None = None, @@ -244,9 +278,15 @@ def load_design_route( # 지표면·노선이 바뀌면 `discard_initial_snapshot()`이 폴더째 지우므로 이 경로가 저절로 # 닫히고 원본 재판독으로 되돌아간다. 트림 **전** 원본이 필요한 호출(도엽 범위 — # surface_params 없음)은 여기를 타지 않는다. + # 읽는 순서 — 계획노선(수정본) → 예상노선(원본) → 초기값 스냅샷(옛 자리). if surface_params: - master = design_route_csv_path(project_root) - if master.is_file(): + for master in ( + planned_route_working_path(project_root), + expected_route_csv_path(project_root), + design_route_csv_path(project_root), + ): + if not master.is_file(): + continue stored = read_planned_route_csv(master) if stored is not None and len(stored.vertices) >= 2: return replace_vertices( diff --git a/main.py b/main.py index f667060e..0d6673c4 100644 --- a/main.py +++ b/main.py @@ -43,6 +43,7 @@ from B04_PreProcess.B04_PreProcess_Router_Watershed import router as b04_watersh from B05_Profile.B05_Profile_Router import router as b05_route_router from B05_Profile.B05_Profile_Router_Corridor import router as b05_corridor_router from B05_Profile.B05_Profile_Router_Lifecycle import router as b05_route_lifecycle_router +from B05_Profile.B05_Profile_Router_Replan import router as b05_route_replan_router from B05_Profile.B05_Profile_Structures_Router import router as b05_structures_router from B06_Section.B06_Section_Router import router as b06_section_router from B06_Section.B06_Section_Router_Confirm import ( @@ -418,6 +419,7 @@ app.include_router(tiles_router, dependencies=protected_with_company) app.include_router(b05_route_router, dependencies=protected_with_company) app.include_router(b05_route_lifecycle_router, dependencies=protected_with_company) app.include_router(b05_corridor_router, dependencies=protected_with_company) +app.include_router(b05_route_replan_router, dependencies=protected_with_company) app.include_router(b05_structures_router, dependencies=protected_with_company) app.include_router(b06_section_router, dependencies=protected_with_company) app.include_router(b06_section_confirm_router, dependencies=protected_with_company)