좌측 「계획노선」 섹션의 [계획노선 편집] 로 큰 모달을 엶(PLAN 0-2). - 등고선 도엽 위에 예상노선(점선)·계획노선(실선)을 함께 그림. 지도 그리기는 배수유역도와 같은 도구(`B04_PreProcess_UI_MapRender`) 재사용. - 노드 끌어 옮기기 · 선 두 번 클릭으로 노드 끼우기 · 오른쪽 클릭으로 지우기, 배경 끌기로 화면 이동, 휠로 확대. - 편집 중에는 계산이 나가지 않음. [확인]에서만 서버가 배수유역부터 재계산하며 그동안 화면을 덮는 안내를 띄움. 끝나면 세션 초안·조회 캐시를 비우고 페이지를 다시 세움. - [예상노선으로] 는 수정본을 지우고 같은 재계산(노선 초기화). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
301 lines
14 KiB
Python
301 lines
14 KiB
Python
"""계획노선 편집 — 노선 두 벌(예상노선·계획노선)과 그 뒤 재계산의 서버 몫.
|
|
|
|
노선은 두 벌이다(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):
|
|
"""고친 계획노선. 정점은 시점 → 종점 순서(사업지 좌표계 m)."""
|
|
|
|
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)
|
|
vertices = [(vertex.x, vertex.y) for vertex in request.vertices]
|
|
written = await asyncio.to_thread(
|
|
_write_working_route, planned_route_working_path(project_root), vertices
|
|
)
|
|
logger.info(
|
|
"계획노선 갈아 끼움: project_id=%s 정점 %d→%d(조밀화)",
|
|
project_id,
|
|
len(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}
|