"""받은 예정노선 원본 꼭짓점(635-2 · 요구 546) — B05 「예정노선 수정」이 고칠 선. 계산 자리 ③ 서버 단독 · 읽기만(파일 안 만듦). 원본 = 올린(받은) 노선 파일 꼭짓점을 사업지 좌표로 · 사용 범위(`projects.route_start_m · route_end_m`) · 지표면 트림까지 — 체인 예정노선 (`load_design_route`)과 같은 차례에서 **조밀화만 뺌**. 끝 = 체인이 자른 `expected_route.csv` 양끝(755-3 · 지표면이 그 뒤 넓어져도 화면 예상노선 · 저장 csv 와 같은 길이). 고친 꼭짓점은 `POST /route/follow` 가 같은 간격(`spacing_m`)으로 조밀화한 뒤 추적 → 안 고친 원본 = 체인 초기선과 거리 0(`test_635_2`). GET /api/projects/{id}/route/plan/source → {status, expected_source: [[x, y], …], spacing_m} (원본 없음 = 빈 목록) 404 프로젝트 없음 """ from __future__ import annotations import asyncio import math from pathlib import Path from typing import Any from uuid import UUID from fastapi import APIRouter from fastapi.responses import JSONResponse from shapely.geometry import LineString, Point from B05_Profile.B05_Profile_Router_Replan import _PROJECT_PATH_MISSING, _project_paths from common_util.common_util_route_geometry import ( clip_route_by_chainage, expected_route_csv_path, load_design_route, read_planned_route_csv, trim_route_to_surface, ) from common_util.common_util_surface_confirmation import get_surface_confirmation_params from common_util.common_util_surface_sampler import build_surface_sampler 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, ) router = APIRouter(prefix="/api/projects", tags=["B05 Plan Source"]) #: 체인 예정노선 조밀화 간격(`load_design_route` 와 같은 값) SPACING_M = ROUTE_DIRECT_LINK_CELL_FACTOR * ROUTE_GRID_RES_M * ROUTE_PLANNED_DENSIFY_SAFETY def source_vertices( root: Path, surface: dict[str, Any] | None, route_range: tuple | None ) -> list[list[float]]: """원본 꼭짓점 — 사업지 좌표 · 범위 · 지표면 트림(지표면을 못 열면 트림 없이) · 조밀화 없음.""" planned = load_design_route(root, None, route_range) if planned is None: return [] points = [(v.x, v.y) for v in planned.vertices] if surface: try: sampler = build_surface_sampler( root / "B04_PreProcess" / "models", str(surface["source_filter"]), str(surface["method"]), bool(surface["smooth"]), ) except (FileNotFoundError, KeyError, OSError): sampler = None if sampler is not None: points = trim_route_to_surface(points, sampler) points = clip_to_expected(root, points) return [[float(x), float(y)] for x, y in points] if len(points) >= 2 else [] def clip_to_expected(root: Path, points: list[tuple[float, float]]) -> list[tuple[float, float]]: """체인이 자른 예정노선 파일 양끝에서 자름(755-3) — 파일 없음 · 2점 미만 = 그대로.""" path = expected_route_csv_path(root) stored = read_planned_route_csv(path) if path.is_file() else None if stored is None or len(stored.vertices) < 2 or len(points) < 2: return points line = LineString(points) ends = [line.project(Point(v.x, v.y)) for v in (stored.vertices[0], stored.vertices[-1])] clipped = clip_route_by_chainage(points, min(ends), max(ends)) # 파일 좌표 반올림(0.1 mm) = 끝이 꼭짓점 바로 앞에 떨어짐 — 1 mm 안 겹친 점은 하나만 return [p for i, p in enumerate(clipped) if not i or math.dist(p, clipped[i - 1]) > 1e-3] @router.get("/{project_id}/route/plan/source", response_model=None) async def read_plan_source(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) async with get_db_pool().acquire() as connection: surface = await get_surface_confirmation_params(connection, str(project_id)) async with connection.cursor() as cursor: await cursor.execute( "SELECT route_start_m, route_end_m FROM projects WHERE id = %s", (str(project_id),), ) row = await cursor.fetchone() vertices = await asyncio.to_thread( source_vertices, paths[0], surface, (row[0], row[1]) if row else None ) return { "status": "success", "project_id": str(project_id), "expected_source": vertices, "spacing_m": SPACING_M, }