"""노선 저장 서버 길(346-3) — 노선 초안을 정본으로: 노선 → 풀기 → 관 · 구조물 따라감 → 종 · 횡단 한 번 → 계획고 옮김 → 횡단 이월 → B06 확정(서버 재계산 · 3D). 배수유역은 체인에서 뗌. API — `POST /api/projects/{id}/route/replan`(`B05_Profile_Router_Replan.replan_route`): 요청 `RouteSaveRequest` = 옛 칸(`vertices` · `initial` · `balanced_profile` · `terrain` · `station_snap`) + `draft` — 계약 `RouteDraft`(`A00_Common/design/design_pipeline_types.ts`) 그대로 `{rev, nodes: [[x, y]…], curveOn: [bool…], curveRadius: [m | null…], curveLock, curveArc, stationSnap, terrain}` · 오면 `vertices` · `station_snap` · `terrain` 대신 이것. `as_initial` — 추천 노선 [적용](초기값 다시 뜸) · 손으로 고친 노선 저장은 false(초기값 그대로). 응답 200 `{status: "success", project_id, route_id, total_length_m, vertex_count, current_initial, initial_refreshed, pipes: {kept, crossing, concave}, structures: {moved, far}, cross_dropped, drainage_stale: true, seconds}`. 오류 400(노드 둘 미만 · 초안 칸 길이 어긋남) · 404(프로젝트 없음) · 409 `{status: "error", message}`(체인 실패 — 노선 · 관 · 구조물 · 종횡단 파일 · 새 노선 행을 옛것으로 되살린 뒤). 규칙: - 관 정본을 지우지 않음 — 좌표를 따라감 · 새 세류선 교차점 기본 관 · 오목 지점 보충만 더함 (`B05_Profile_Route_Save_Parts`) · 배수유역 저장분은 노선 지문이 달라 「낡음」(B04 `_resolve`). - 계획고 = 바뀐 구간만 새 지반 첫 계획선 · 그 밖 사용자 계획고 지킴 (`B05_Profile_Engine_Grade_Carry`) · 추천 「종단 반영」 켬이면 균형 종단 (`apply_profile_target`). - 초기값은 여기서 안 뜸 — 부르는 쪽이 `as_initial` 일 때만(자동설계 체인 직후 + 추천 [적용]). """ from __future__ import annotations import asyncio import json import logging import shutil import tempfile import time from dataclasses import asdict from pathlib import Path from typing import Any, Literal from uuid import UUID import numpy as np from fastapi.responses import JSONResponse from pydantic import BaseModel, Field from B05_Profile.B05_Profile_Route_Save_Parts import ( add_crossing_pipes, carry_cross_inputs, concave_chainages, fill_concave_pipes, follow_pipes, station_keys, vertices_of, ) from B05_Profile.B05_Profile_Schema import RouteReplanRequest, RouteVertexInput from config.config_db import get_db_pool logger = logging.getLogger(__name__) class RouteDraftInput(BaseModel): """노선 초안 — 계약 `RouteDraft` 모양 그대로(칸 이름 camelCase).""" rev: int = 0 nodes: list[tuple[float, float]] = Field(default_factory=list) curveOn: list[bool] = Field(default_factory=list) # noqa: N815 — 계약 칸 이름 curveRadius: list[float | None] = Field(default_factory=list) # noqa: N815 curveLock: list[str | None] = Field(default_factory=list) # noqa: N815 curveArc: list[float | None] = Field(default_factory=list) # noqa: N815 stationSnap: bool = False # noqa: N815 terrain: Literal["las", "sheet"] | None = None class RouteSaveRequest(RouteReplanRequest): """노선 저장 요청 — 옛 [노선 반영] 칸 + 초안(`draft`) · 추천 [적용](`as_initial`).""" draft: RouteDraftInput | None = None as_initial: bool = False def resolved(self) -> RouteReplanRequest: """초안이 오면 노드 · 곡선 · 스냅 · 지형을 옛 칸으로 편다. 칸 길이가 어긋나면 ValueError.""" if self.draft is None: return self draft = self.draft count = len(draft.nodes) for name in ("curveOn", "curveRadius"): values = getattr(draft, name) if values and len(values) != count: raise ValueError( f"노선 초안 {name} 길이({len(values)})가 노드 수({count})와 다릅니다." ) vertices = [ RouteVertexInput( x=x, y=y, curve=draft.curveOn[index] if draft.curveOn else True, radius_m=draft.curveRadius[index] if draft.curveRadius else None, ) for index, (x, y) in enumerate(draft.nodes) ] return self.model_copy( update={ "vertices": vertices, "station_snap": draft.stationSnap, "terrain": draft.terrain, } ) class _Backup: """체인이 고치는 파일을 손에 쥠 — 실패하면 그대로 되돌림(노선 · 관 · 구조물 · 종횡단).""" def __init__(self, project_root: Path, paths: list[Path], folders: list[Path]) -> None: self.held = tempfile.mkdtemp(prefix="route_save_", dir=str(project_root)) self.files = {path: path.read_bytes() if path.is_file() else None for path in paths} self.folders: dict[Path, Path | None] = {} for index, folder in enumerate(folders): copy = Path(self.held) / f"dir{index}" self.folders[folder] = shutil.copytree(folder, copy) if folder.is_dir() else None def restore(self) -> None: for path, saved in self.files.items(): if saved is None: path.unlink(missing_ok=True) else: path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(saved) for folder, copy in self.folders.items(): shutil.rmtree(folder, ignore_errors=True) if copy is not None: shutil.copytree(copy, folder) def drop(self) -> None: shutil.rmtree(self.held, ignore_errors=True) class _Fail(RuntimeError): """체인 한 단계 실패 — 사유를 그대로 화면까지(까닭을 덮지 않음).""" def _reason(response: JSONResponse) -> str: try: return bytes(response.body).decode("utf-8", "replace")[:300] except Exception: # noqa: BLE001 — 사유는 로그 · 응답용 return f"status={response.status_code}" def _read_json(path: Path) -> dict[str, Any]: try: return json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError): return {} def _xy(project_root: Path, route_data_path: str | None) -> list[list[float]]: from B05_Profile.B05_Profile_Engine_Sections import _load_route_polyline if not route_data_path: return [] try: return [[x, y] for x, y, _ in _load_route_polyline(project_root, route_data_path)] except (OSError, ValueError): return [] def _ground_along(project_root: Path, selection: dict[str, Any], vertices: list) -> tuple: """새 노선 지반 종단(2 m) — 오목 지점 찾기. 지표면을 못 열면 빈 배열(보충 건너뜀).""" from shapely.geometry import LineString from B05_Profile.B05_Profile_Route_Save_Parts import CONCAVE_STEP_M from common_util.common_util_surface_sampler import build_surface_sampler try: sampler = build_surface_sampler( project_root / "B04_PreProcess" / "models", str(selection.get("source_filter")), str(selection.get("method") or "dtm"), bool(selection.get("smooth")), ) except (FileNotFoundError, KeyError, OSError, ValueError): logger.warning("노선 저장: 지표면을 못 열어 오목 지점 보충을 건너뜀") return np.zeros(0), np.zeros(0) line = LineString([(vertex.x, vertex.y) for vertex in vertices]) chainage = np.arange(0.0, line.length + 1e-9, CONCAVE_STEP_M) xy = np.array([line.interpolate(float(s)).coords[0] for s in chainage], dtype=np.float64) z, valid = sampler.sample_xy(xy) return chainage[valid], np.asarray(z, dtype=np.float64)[valid] async def _place_pipes( project_id: UUID, project_root: Path, stored_path: str, selection: dict[str, Any], old_pipes: list, old_xy: list[list[float]], points: list[dict[str, float]], ) -> tuple[list, dict[str, int]]: """관 따라감 → 새 교차점 기본 관 → 오목 지점 보충 → 정본 저장(새 노선 지문).""" from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import _base_pipes from B04_PreProcess.B04_PreProcess_Router_Watershed import _prepare from common_util.common_util_drainage_pipes import route_signature, save_pipe_points prepared = await _prepare(project_id) if isinstance(prepared, JSONResponse): # 도엽 · 노선 파일 없음 — 따라감만 vertices, streams = vertices_of([(p["x"], p["y"]) for p in points]), [] else: vertices, streams = prepared["vertices"], prepared["streams"] pairs = follow_pipes(old_pipes, vertices) total = float(vertices[-1].chainage_m) crossing = add_crossing_pipes(pairs, _base_pipes(vertices, streams), old_xy, total) chainage, ground = await asyncio.to_thread(_ground_along, project_root, selection, vertices) concave = fill_concave_pipes(pairs, concave_chainages(chainage, ground), vertices, old_xy) await asyncio.to_thread( save_pipe_points, stored_path, route_signature(vertices), [pipe for _, pipe in pairs], vertices, ) return pairs, {"kept": len(old_pipes), "crossing": crossing, "concave": concave} async def _carry_profile( project_root: Path, relative: str, route_id: int, old_alignment: dict[str, Any], old_xy: list[list[float]], new_xy: list[list[float]], ) -> None: """바뀐 구간만 새 지반 첫 계획선 · 그 밖 사용자 계획고 · 종단곡선 R 은 길이 차만큼 밀어 지킴.""" from B05_Profile.B05_Profile_Engine_Grade_Carry import carry_profile_edits, changed_span from B05_Profile.B05_Profile_Repository import update_longitudinal_grade_summary from B05_Profile.B05_Profile_Router import _apply_alignment_edits from common_util.common_util_json import atomic_write_json if not old_alignment.get("base_pvi"): return # 옛 선형 없음 — 새 지반 첫 계획선 그대로 path = project_root / relative longitudinal = _read_json(path) alignment = longitudinal.get("profile_alignment") or {} span = changed_span([tuple(p) for p in old_xy], [tuple(p) for p in new_xy]) if span is None: # 같은 노선 — 옛 계획선 통째 base, edits = old_alignment["base_pvi"], old_alignment.get("edits") or {} else: base, edits = carry_profile_edits( old_alignment["base_pvi"], old_alignment.get("edits"), span, alignment["base_pvi"] ) alignment["base_pvi"] = base await asyncio.to_thread(atomic_write_json, path, longitudinal) _alignment, summary, _dropped = await asyncio.to_thread( _apply_alignment_edits, project_root, relative, edits ) pool = get_db_pool() async with pool.acquire() as connection: await connection.begin() await update_longitudinal_grade_summary( connection, route_id=route_id, grade_summary=summary ) await connection.commit() async def _carry_cross( project_id: UUID, project_root: Path, route_id: int, relative: str, old_long: dict[str, Any], old_designs: list[dict[str, Any]], old_pipes: list, old_structures: list, pairs: list, interval_m: float, standard: dict[str, Any] | None, ) -> int: """측점 열쇠로 옛 횡단 입력을 새 측점에 얹고 전 측점을 새 계획고로 셈 · 버린 수 돌려줌.""" from B05_Profile.B05_Profile_Structures_Repository import load_structures from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs from B06_Section.B06_Section_Router_Confirm import _rowless_station_chainages from B06_Section.B06_Section_Router_Design import ( CARRY_INPUT_KEYS, STRUCTURE_ADJUST_KEYS, compute_default_designs, ) new_long = _read_json(project_root / relative) _, new_structures = load_structures(str(project_root)) old_keys = station_keys( old_long.get("stations") or [], list(enumerate(old_pipes)), old_structures, interval_m ) new_keys = station_keys(new_long.get("stations") or [], pairs, new_structures, interval_m) designs = { round(float(row["chainage_m"]), 3): row["design"] for row in old_designs if isinstance(row.get("design"), dict) } inputs, adjusts, dropped = carry_cross_inputs( old_keys, new_keys, designs, CARRY_INPUT_KEYS, STRUCTURE_ADJUST_KEYS ) chainages = await asyncio.to_thread(_rowless_station_chainages, project_root, relative, []) entries = await asyncio.to_thread( compute_default_designs, project_root, relative, chainages, standard, inputs ) entries = [(at, {**design, **adjusts.get(round(at, 3), {})}) for at, design in entries] pool = get_db_pool() async with pool.acquire() as connection: await connection.begin() try: await merge_cross_section_designs( connection, route_id=route_id, entries=entries, replace=True, project_id=project_id ) await connection.commit() except Exception: await connection.rollback() raise return dropped async def _rollback(project_id: UUID, old_route_id: int, params: dict[str, Any]) -> None: """새 노선 행(종횡단 함께 지워짐)을 지우고 stage 2 params 를 옛것으로 — 최신 노선 = 옛 노선.""" from common_util.common_util_workflow_state import start_stage pool = get_db_pool() async with pool.acquire() as connection: await connection.begin() try: async with connection.cursor() as cursor: await cursor.execute( "DELETE FROM routes WHERE project_id = %s AND id > %s", (str(project_id), old_route_id), ) await start_stage(cursor, str(project_id), 2, params) await connection.commit() except Exception: await connection.rollback() logger.exception("노선 저장 되살림(DB) 실패: project_id=%s", project_id) async def run_route_save_chain( project_id: UUID, project_root: Path, stored_path: str, surface_model_id: int, selection: dict[str, Any], old_route: dict[str, Any], points: list[dict[str, float]], previous_params: dict[str, Any], profile_target: tuple[list[float], list[float]] | None = None, ) -> dict[str, Any]: """노선 저장 체인 — 성공이면 요약 dict · 실패면 `{"error": 사유}`(파일 · 행은 되살린 뒤).""" import aiomysql from B03_FileInput.B03_FileInput_Service_Chain import _log_steps, redesign_solve_request from B05_Profile.B05_Profile_Engine_Sections import _STAGE_SUBDIR from B05_Profile.B05_Profile_Router import ( _section_options, confirm_latest_route, solve_route_record, ) from B05_Profile.B05_Profile_Structures_Repository import load_structures, structures_file_path from B05_Profile.B05_Profile_Structures_Router import follow_structures_to_route from B06_Section.B06_Section_Repository import ( get_cross_section_designs, get_latest_section_options, get_longitudinal_section, ) from B06_Section.B06_Section_Router import regenerate_route_sections from B06_Section.B06_Section_Router_Confirm import confirm_sections from B06_Section.B06_Section_Router_Design import stored_standard_cross_section from B06_Section.B06_Section_Schema import SectionConfirmRequest from common_util.common_util_drainage_pipes import pipe_points_path, read_pipe_points_file from common_util.common_util_initial_snapshot import clear_designing, mark_designing from common_util.common_util_workflow_state import get_workflow_state marks = [("시작", time.perf_counter())] old_route_id = int(old_route["id"]) 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)) old_long_row = await get_longitudinal_section(connection, project_id, old_route_id) old_designs = await get_cross_section_designs(connection, old_route_id) old_section_options = await get_latest_section_options(connection, project_id) stage2 = next((s for s in state.get("stages", []) if int(s.get("stage_no", -1)) == 2), None) params = (stage2 or {}).get("params") or {} relative_old = str((old_long_row or {}).get("longitudinal_file_path") or "") old_long = _read_json(project_root / relative_old) if relative_old else {} old_xy = _xy(project_root, old_route.get("route_data_path")) old_pipes = read_pipe_points_file(pipe_points_path(stored_path)) _, old_structures = load_structures(str(project_root)) stage_dir = project_root / _STAGE_SUBDIR backup = _Backup( project_root, [ project_root / str(old_route.get("route_data_path") or "B05_Profile/route/x"), pipe_points_path(stored_path), Path(structures_file_path(str(project_root))), ], [stage_dir / "longitudinal", stage_dir / "cross_sections"], ) mark_designing(project_root) try: # 구조물 = 땅 위 자리 따라감(옛 노선 파일이 아직 옛 것일 때 — 좌표 없는 옛 자료 채움) try: structures = await asyncio.to_thread( follow_structures_to_route, project_root, old_route.get("route_data_path"), points ) except Exception: # noqa: BLE001 — 따라감 실패 = 옛 누가거리 그대로(비치명) logger.exception("구조물 따라감 실패(옛 누가거리 유지): project_id=%s", project_id) structures = {"moved": 0, "far": 0} request = redesign_solve_request(params, selection, surface_model_id) solved: Any = await solve_route_record(project_id, request, generate_sections=False) if isinstance(solved, JSONResponse): raise _Fail(f"노선 풀기 실패({solved.status_code}): {_reason(solved)}") new_route_id = int(solved.route_id) confirmed = await confirm_latest_route(project_id, None, mark_stage_complete=False) if isinstance(confirmed, JSONResponse): raise _Fail(f"노선 확정 실패({confirmed.status_code})") new_xy = _xy(project_root, solved.route_data_path) marks.append(("노선 풀기 · 구조물 따라감", time.perf_counter())) pairs, pipes = await _place_pipes( project_id, project_root, stored_path, selection, old_pipes, old_xy, points ) marks.append(("관 따라감 · 교차점 · 오목 보충", time.perf_counter())) seed = asdict(_section_options(request, old_section_options)) seed.pop("extra_stations", None) made = await regenerate_route_sections( project_id, new_route_id, float(seed["cross_half_width_m"]), stored_options=seed ) if isinstance(made, JSONResponse): raise _Fail(f"종횡단 생성 실패({made.status_code}): {_reason(made)}") async with pool.acquire() as connection: new_long_row = await get_longitudinal_section(connection, project_id, new_route_id) relative = str((new_long_row or {}).get("longitudinal_file_path") or "") marks.append(("종횡단 한 번", time.perf_counter())) if profile_target is not None: from B05_Profile.B05_Profile_Router_RouteInitial import apply_profile_target await apply_profile_target(project_id, new_route_id, profile_target) else: await _carry_profile( project_root, relative, new_route_id, old_long.get("profile_alignment") or {}, old_xy, new_xy, ) marks.append(("계획고 옮김", time.perf_counter())) standard = stored_standard_cross_section(old_long_row) dropped = await _carry_cross( project_id, project_root, new_route_id, relative, old_long, old_designs, old_pipes, old_structures, pairs, float(seed["station_interval_m"]), standard, ) marks.append(("횡단 이월(측점 열쇠)", time.perf_counter())) done = await confirm_sections( project_id, new_route_id, SectionConfirmRequest(standard_cross_section=standard) if standard else None, mark_stage_complete=False, ) if isinstance(done, JSONResponse): raise _Fail(f"종횡단 확정 실패({done.status_code})") marks.append(("B06 확정 · 서버 재계산 · 3D", time.perf_counter())) except Exception as exc: # noqa: BLE001 — 어느 단계든 옛 노선으로 되살림 if not isinstance(exc, _Fail): logger.exception("노선 저장 체인 실패: project_id=%s", project_id) await asyncio.to_thread(backup.restore) await _rollback(project_id, old_route_id, previous_params) return {"error": str(exc) if isinstance(exc, _Fail) else f"노선 저장 중 오류: {exc}"} finally: backup.drop() clear_designing(project_root) _log_steps("재확정 체인(노선 저장)", marks) return { "route_id": new_route_id, "total_length_m": float(solved.total_length_m or 0.0), "pipes": pipes, "structures": structures, "cross_dropped": dropped, "drainage_stale": True, "seconds": round(marks[-1][1] - marks[0][1], 2), }