사용자 확정(2026-09-07) — 사용자가 잡는 것은 **곡선 시작·끝점**이고 필요하면
반지름을 직접 바꿈. 정점 목록만으로는 어디부터 어디까지가 한 곡선인지,
그 반지름이 얼마인지 알 수 없어 편집·도면이 같은 값을 못 봄.
- RouteCurve 신설 — 교각점(apex) · 반지름 · 접선길이 · 내각 ·
곡선 시작점 · 끝점 · 그 곡선이 대신하는 꺾임점 구간 · 위반.
반지름을 바꿔도 apex 는 안 움직임(「곡선 반지름 변경 시 주변 직선 각도
구속」이 그 뜻 — 직선이 고정이라 접선점만 미끄러짐).
- `planned_route_curves.json` 으로 저장(노드 CSV 옆).
- `GET /route/plan` 이 `curves` 를 함께 돌려줌.
곁들여 고친 것 — 묶음을 도로 쪼갤 때 **별표2 의 155° 를 다시 안 봐서**
펴진 자리(내각 159°)에도 곡선이 생기고 있었음. 쪼갤 때 다시 검사함.
그 결과 용화 노선이 곡선 24 → 13곳, 정점 179 → 145 로 제자리를 찾음.
실측(용화, 예상노선 331점) — 노드 28 · 곡선 13 · 정점 145 · 위반 0 ·
벗어남 최대 3.57m 평균 1.081m(고치기 전 1.077m). 반지름은 12~28m 로
자리마다 갈림(전에는 전부 12m).
pytest 420 passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
513 lines
25 KiB
Python
513 lines
25 KiB
Python
"""계획노선 편집 — 노선 두 벌(예상노선·계획노선)과 그 뒤 재계산의 서버 몫.
|
|
|
|
노선은 **세 벌**이다(2026-09-06 사용자 확정 → 같은 날 정정, PLAN 0-7).
|
|
|
|
- **예상노선**(원본) — 파일 업로드 자동 체인이 낸 것. `B05_Profile/route/expected_route.csv`.
|
|
⚠ 이것은 폴리라인이 아니라 **점 묶음**이고 규칙 없는 폴리라인과도 맞지 않는다
|
|
(2026-09-06 사용자 확인). 어떤 경로로도 고치지 않는다.
|
|
- **계획노선 초기본** — 위를 복사해 **폴리라인으로 바꾼 것**. `planned_route_initial.csv`.
|
|
**불변의 초기 데이터**이며, 유토곡선·3D 에 투영되는 선도 이것이다. 곡선은 지식DB
|
|
기준(별표2 Ⅰ.2.다 — 설계속도·지형별 최소곡선반지름, 내각 155° 이상은 생략)으로 끼운다.
|
|
- **계획노선**(수정본) — 초기본에서 시작해 사용자가 **노드를 잡아** 고친 것.
|
|
`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 functools
|
|
import json
|
|
import logging
|
|
import time
|
|
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_initial_path,
|
|
planned_route_working_path,
|
|
read_planned_route_csv,
|
|
write_route_csv,
|
|
)
|
|
from common_util.common_util_route_polyline import build_planned_polyline
|
|
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"
|
|
|
|
|
|
async def _min_plan_radius_m(project_id: UUID) -> float:
|
|
"""이 프로젝트에 적용할 법정 최소곡선반지름(m) — 임도 종류·설계속도·지형으로 고른다.
|
|
|
|
값의 출처는 지식DB(`01_임도/02_상세설계/평면선형.md`, 별표2 Ⅰ.2.다)이고 산식은 이미
|
|
`B05_Profile_Engine_Grade.legal_plan_radius_min_m` 에 있다 — 여기서 다시 짜지 않는다.
|
|
|
|
읽는 자리 — 임도 종류는 `projects.road_type`, 설계속도·지형은 **워크플로 stage 2 params**
|
|
(노선 풀기 요청이 남긴 값)다. `projects` 에는 설계속도·지형 칸이 없다(2026-09-06 확인:
|
|
있는 것은 `road_type` 뿐). 못 읽으면 가장 완화된 조건으로 떨어진다 — 막지 않고 위반
|
|
표시만 하기 때문이다.
|
|
"""
|
|
import aiomysql
|
|
|
|
from B05_Profile.B05_Profile_Engine_Grade import legal_plan_radius_min_m, resolve_design_speed
|
|
from common_util.common_util_workflow_state import get_workflow_state
|
|
|
|
grade_class, design_speed, terrain = "work", None, "special"
|
|
try:
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection:
|
|
async with connection.cursor() as cursor:
|
|
await cursor.execute(
|
|
"SELECT road_type FROM projects WHERE id = %s", (str(project_id),)
|
|
)
|
|
row = await cursor.fetchone()
|
|
if row and row[0]:
|
|
grade_class = str(row[0])
|
|
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 = (stage2 or {}).get("params") or {}
|
|
speed = params.get("design_speed_kph")
|
|
if isinstance(speed, (int, float)):
|
|
design_speed = int(speed)
|
|
if params.get("terrain_type") in ("normal", "special"):
|
|
terrain = str(params["terrain_type"])
|
|
except Exception: # noqa: BLE001 — 설정을 못 읽어도 폴리라인화는 이어 간다
|
|
logger.exception("최소곡선반지름 설정을 못 읽어 기본값을 씁니다: %s", project_id)
|
|
return legal_plan_radius_min_m(resolve_design_speed(grade_class, design_speed), terrain)
|
|
|
|
|
|
def _nodes_path(path: Path) -> Path:
|
|
"""그 폴리라인을 낳은 **노드** 파일 자리 — `planned_route.csv` → `planned_route_nodes.csv`."""
|
|
return path.with_name(f"{path.stem}_nodes.csv")
|
|
|
|
|
|
def _curves_path(path: Path) -> Path:
|
|
"""그 폴리라인의 **곡선 성분** 자리 — `planned_route.csv` → `planned_route_curves.json`.
|
|
|
|
왜 따로 남기나(2026-09-07 사용자 확정) — 계획노선은 「직선 > 곡선 > 직선」이고 사용자가
|
|
잡는 것은 **곡선 시작·끝점**이며 반지름도 직접 바꾼다. 정점 목록만으로는 어디부터
|
|
어디까지가 한 곡선인지, 그 반지름이 얼마인지 알 수 없어 편집·도면이 같은 값을 못 본다.
|
|
"""
|
|
return path.with_name(f"{path.stem}_curves.json")
|
|
|
|
|
|
def _read_curves(path: Path) -> list[dict]:
|
|
"""저장해 둔 곡선 성분. 없거나 못 읽으면 빈 목록(옛 프로젝트)."""
|
|
target = _curves_path(path)
|
|
if not target.is_file():
|
|
return []
|
|
try:
|
|
data = json.loads(target.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
logger.warning("계획노선 곡선 성분을 읽지 못했습니다: %s", target)
|
|
return []
|
|
curves = data.get("curves") if isinstance(data, dict) else None
|
|
return curves if isinstance(curves, list) else []
|
|
|
|
|
|
def _write_planned_polyline(
|
|
path: Path, points: list[tuple[float, float]], radius_m: float, *, simplify: bool = True
|
|
) -> dict:
|
|
"""점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다.
|
|
|
|
**노드도 함께 남긴다** — 노드는 폴리라인에서 되뽑을 수 없다. 폴리라인에는 원호 위 점이
|
|
섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를
|
|
때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). 낳은 값을 그대로 보관한다.
|
|
"""
|
|
result = build_planned_polyline(points, min_radius_m=radius_m, simplify=simplify)
|
|
write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices])
|
|
write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes])
|
|
_curves_path(path).write_text(
|
|
json.dumps(
|
|
{"min_radius_m": round(radius_m, 3), "curves": [c.as_dict() for c in result.curves]},
|
|
ensure_ascii=False,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
return {
|
|
"nodes": len(result.nodes),
|
|
"curves": result.curve_count,
|
|
"violations": result.violation_count,
|
|
"vertices": len(result.vertices),
|
|
}
|
|
|
|
|
|
def _ensure_planned_initial(project_root: Path, radius_m: float) -> dict | None:
|
|
"""계획노선 **초기 폴리라인**이 없거나 낡았으면 예상노선을 폴리라인화해 세운다.
|
|
|
|
「낡았다」 = 예상노선 파일이 더 나중에 쓰였다. 파일을 다시 올리면 예상노선이 새로
|
|
깔리는데 초기본이 옛 노선인 채로 남으면 노선 초기화가 옛 자리로 돌아간다.
|
|
"""
|
|
target = planned_route_initial_path(project_root)
|
|
source = expected_route_csv_path(project_root)
|
|
if target.is_file():
|
|
if not source.is_file() or source.stat().st_mtime <= target.stat().st_mtime:
|
|
return None
|
|
logger.info("예상노선이 새로 깔려 초기 폴리라인을 다시 만듭니다: %s", target)
|
|
points = [(x, y) for x, y in _vertices_of(source)]
|
|
if len(points) < 2:
|
|
return None
|
|
summary = _write_planned_polyline(target, points, radius_m)
|
|
logger.info(
|
|
"계획노선 초기 폴리라인 생성: %s (노드 %d · 곡선 %d · 위반 %d · 정점 %d)",
|
|
target,
|
|
summary["nodes"],
|
|
summary["curves"],
|
|
summary["violations"],
|
|
summary["vertices"],
|
|
)
|
|
return summary
|
|
|
|
|
|
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 (
|
|
_log_steps,
|
|
_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
|
|
|
|
marks = [("시작", time.perf_counter())]
|
|
|
|
# 횡단배수 지점은 새 노선에서 새로 계산한다 — 저장분을 남기면 옛 자리가 살아난다.
|
|
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()
|
|
|
|
marks.append(("재계산 준비(관 삭제·노선 읽기·stage 기록)", time.perf_counter()))
|
|
|
|
failure = await run_redesign_chain(project_id, int(surface_model_id), selection)
|
|
marks.append(("재확정 체인", time.perf_counter()))
|
|
_log_steps("노선 [확인] 재계산", marks)
|
|
if failure:
|
|
# 체인이 끊기면 **노선만 새것으로 갈아 끼워진 채** 종횡단은 옛 노선에 남아 어긋난다
|
|
# (2026-09-06 실측: 배수유역·관은 새 노선, 종횡단은 옛 노선 → 배수관 측점 9 → 0).
|
|
# 부르는 쪽이 계획노선을 되돌리도록 사유를 올려 보낸다.
|
|
return {"error": failure}
|
|
|
|
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:
|
|
"""예상노선(점 묶음)·계획노선(폴리라인)·편집할 노드를 함께 돌려준다.
|
|
|
|
화면이 그리는 것은 셋이다 — 예상노선은 **점선**, 계획노선은 **실선**, 그리고 사용자가
|
|
잡아 옮기는 **노드**(꺾임점). 노드에는 그 자리에 끼운 반지름·내각·법정 위반 표시가
|
|
붙어 있어 화면이 그대로 보여 줄 수 있다(2026-09-06 사용자 지시).
|
|
"""
|
|
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))
|
|
|
|
radius_m = await _min_plan_radius_m(project_id)
|
|
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
|
|
working = await asyncio.to_thread(_vertices_of, planned_route_working_path(project_root))
|
|
initial = await asyncio.to_thread(_vertices_of, planned_route_initial_path(project_root))
|
|
planned = working or initial or expected
|
|
|
|
# 노드는 **저장해 둔 것을 그대로** 쓴다 — 폴리라인에서 되뽑으면 안 된다. 정점에 원호
|
|
# 위 점이 섞여 있어 다시 단순화하면 꺾임점이 지워지고, 그 결과로 만든 폴리라인을 또
|
|
# 단순화하게 되어 [확인]마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119).
|
|
saved_nodes = await asyncio.to_thread(
|
|
_vertices_of,
|
|
_nodes_path(
|
|
planned_route_working_path(project_root)
|
|
if working
|
|
else planned_route_initial_path(project_root)
|
|
),
|
|
)
|
|
node_source = saved_nodes or (working or expected)
|
|
saved_curves = await asyncio.to_thread(
|
|
_read_curves,
|
|
planned_route_working_path(project_root)
|
|
if working
|
|
else planned_route_initial_path(project_root),
|
|
)
|
|
outline = await asyncio.to_thread(
|
|
build_planned_polyline,
|
|
[(x, y) for x, y in node_source],
|
|
min_radius_m=radius_m,
|
|
# 저장해 둔 노드면 이미 꺾임점이라 다시 뽑지 않는다. 없을 때(옛 프로젝트)만 뽑는다.
|
|
simplify=not saved_nodes,
|
|
)
|
|
return {
|
|
"status": "success",
|
|
"project_id": str(project_id),
|
|
"expected": expected,
|
|
"planned": planned,
|
|
"nodes": [node.as_dict() for node in outline.nodes],
|
|
# 곡선 성분 — 화면이 **곡선 시작·끝점**을 손잡이로 그리고 반지름 칸을 띄우는 재료다
|
|
# (2026-09-07 사용자 확정). 저장분이 있으면 그것을, 없으면 방금 뽑은 것을 준다.
|
|
"curves": saved_curves or [curve.as_dict() for curve in outline.curves],
|
|
"min_radius_m": round(radius_m, 2),
|
|
"curve_count": len(saved_curves) if saved_curves else outline.curve_count,
|
|
"violation_count": outline.violation_count,
|
|
"edited": bool(working),
|
|
}
|
|
|
|
|
|
@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)
|
|
radius_m = await _min_plan_radius_m(project_id)
|
|
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
|
|
# 화면이 보낸 것은 **노드(꺾임점)** 다 — 같은 R 규칙으로 다시 폴리라인을 만든다.
|
|
# 노드만 옮기면 선이 저절로 규칙을 지키는 것이 이 구조의 목적이다(2026-09-06 사용자).
|
|
# 받은 것은 이미 꺾임점이므로 **다시 뽑지 않는다** — 뽑으면 사용자가 둔 노드가 지워진다.
|
|
nodes = [(vertex.x, vertex.y) for vertex in request.vertices]
|
|
# 체인이 끊기면 되돌릴 수 있게 이전 수정본을 손에 쥔다 — 실패했는데 노선만 바뀌면
|
|
# 종횡단과 어긋난 채 굳고, 다시 누를수록 어긋남이 쌓인다(2026-09-06 실측).
|
|
working_path = planned_route_working_path(project_root)
|
|
previous = working_path.read_bytes() if working_path.is_file() else None
|
|
summary = await asyncio.to_thread(
|
|
functools.partial(_write_planned_polyline, simplify=False), working_path, nodes, radius_m
|
|
)
|
|
written = summary["vertices"]
|
|
logger.info(
|
|
"계획노선 갈아 끼움: project_id=%s 노드 %d → 정점 %d (곡선 %d · 위반 %d · R %.1fm)",
|
|
project_id,
|
|
len(nodes),
|
|
written,
|
|
summary["curves"],
|
|
summary["violations"],
|
|
radius_m,
|
|
)
|
|
result = await _recompute(project_id, project_root, stored_path)
|
|
if "error" in result:
|
|
# 노선을 되돌린다 — 실패했는데 새 노선만 남으면 화면·정본이 어긋난 채 굳는다.
|
|
if previous is None:
|
|
working_path.unlink(missing_ok=True)
|
|
else:
|
|
working_path.write_bytes(previous)
|
|
logger.warning(
|
|
"계획노선 갈아 끼우기 실패 — 노선을 되돌렸습니다: project_id=%s 사유=%s",
|
|
project_id,
|
|
result["error"],
|
|
)
|
|
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)
|
|
radius_m = await _min_plan_radius_m(project_id)
|
|
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
|
|
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}
|