From 14ba5f96b77db1ec5f88d2aab90e5d74103e10aa Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 19 Jul 2026 00:19:59 +0900 Subject: [PATCH] 260719_0 --- B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts | 4 +- B05_wf2_Route/B05_wf2_Route_Repository.py | 26 +++++++++ B05_wf2_Route/B05_wf2_Route_Router.py | 71 ++++++++++++++--------- B05_wf2_Route/B05_wf2_Route_Schema.py | 5 +- B05_wf2_Route/B05_wf2_Route_UI_Page.ts | 15 ++++- B05_wf2_Route/B05_wf2_Route_UI_Panel.ts | 16 ++--- 6 files changed, 95 insertions(+), 42 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index b4d13aff..b33fda70 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -60,8 +60,8 @@ export interface RouteSolveResponse { metrics: Record; required_points_ok: boolean; route_data_path: string; - longitudinal_length_m: number; - cross_section_count: number; + longitudinal_length_m: number | null; + cross_section_count: number | null; } /** 경로 확정 결과 (RouteConfirmResponse) */ diff --git a/B05_wf2_Route/B05_wf2_Route_Repository.py b/B05_wf2_Route/B05_wf2_Route_Repository.py index 44f5ce45..e9692695 100644 --- a/B05_wf2_Route/B05_wf2_Route_Repository.py +++ b/B05_wf2_Route/B05_wf2_Route_Repository.py @@ -206,3 +206,29 @@ async def confirm_route(connection: aiomysql.Connection, route_id: int) -> None: "UPDATE routes SET status = 'CONFIRMED' WHERE id = %s", (route_id,), ) + + +async def get_surface_crs_epsg( + connection: aiomysql.Connection, project_id: UUID, surface_model_id: int +) -> int | None: + """종횡단 메타데이터용 좌표계를 조회한다. + + 지표면 모델 crs_epsg가 NULL이면 같은 프로젝트 input_files의 감지된 + 좌표계로 폴백한다 (B06 get_confirmed_route_context와 동일 규칙). + """ + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """ + SELECT COALESCE( + (SELECT sm.crs_epsg FROM surface_models sm WHERE sm.id = %s), + (SELECT f.crs_epsg + FROM input_files f + WHERE f.project_id = %s AND f.crs_epsg IS NOT NULL + ORDER BY f.id DESC + LIMIT 1) + ) AS crs_epsg + """, + (surface_model_id, str(project_id)), + ) + row = await cursor.fetchone() + return int(row["crs_epsg"]) if row and row["crs_epsg"] is not None else None diff --git a/B05_wf2_Route/B05_wf2_Route_Router.py b/B05_wf2_Route/B05_wf2_Route_Router.py index adf483b9..2da7ea5f 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router.py +++ b/B05_wf2_Route/B05_wf2_Route_Router.py @@ -20,6 +20,7 @@ from B05_wf2_Route.B05_wf2_Route_Repository import ( create_route_statistics, get_latest_route, get_route_points, + get_surface_crs_epsg, insert_route_points, ) from B05_wf2_Route.B05_wf2_Route_Schema import ( @@ -198,35 +199,51 @@ async def solve_route( ) raise - sections = await asyncio.to_thread( - run_section_generation, - project_root, - design["route_data_path"], - request.filter_key, - request.method, - request.smooth, - options=_section_options(request), - ) - await connection.begin() + # 종횡단 생성 실패는 저장된 경로를 무효화하지 않으므로 비치명적으로 처리한다. + longitudinal_length_m: float | None = None + cross_section_count: int | None = None try: - await delete_sections_for_route(connection, route_id) - await create_longitudinal_section( - connection, - project_id=project_id, - route_id=route_id, - data=sections["longitudinal"]["data"], - longitudinal_file_path=sections["longitudinal"]["file_path"], + crs_epsg = await get_surface_crs_epsg( + connection, project_id, request.surface_model_id ) - await insert_cross_sections( - connection, - project_id=project_id, - route_id=route_id, - sections=sections["cross_sections"], + sections = await asyncio.to_thread( + run_section_generation, + project_root, + design["route_data_path"], + request.filter_key, + request.method, + request.smooth, + options=_section_options(request), + crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None, ) - await connection.commit() + await connection.begin() + try: + await delete_sections_for_route(connection, route_id) + await create_longitudinal_section( + connection, + project_id=project_id, + route_id=route_id, + data=sections["longitudinal"]["data"], + longitudinal_file_path=sections["longitudinal"]["file_path"], + ) + await insert_cross_sections( + connection, + project_id=project_id, + route_id=route_id, + sections=sections["cross_sections"], + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + longitudinal_length_m = sections["longitudinal"]["data"]["length_m"] + cross_section_count = len(sections["cross_sections"]) except Exception: - await connection.rollback() - raise + logger.exception( + "B05 종횡단 생성 실패 (경로는 저장됨): project_id=%s route_id=%s", + project_id, + route_id, + ) return RouteSolveResponse( project_id=str(project_id), @@ -235,8 +252,8 @@ async def solve_route( metrics=metrics, required_points_ok=solver["required_points_ok"], route_data_path=design["route_data_path"], - longitudinal_length_m=sections["longitudinal"]["data"]["length_m"], - cross_section_count=len(sections["cross_sections"]), + longitudinal_length_m=longitudinal_length_m, + cross_section_count=cross_section_count, ) except LookupError as exc: async with pool.acquire() as connection, connection.cursor() as cursor: diff --git a/B05_wf2_Route/B05_wf2_Route_Schema.py b/B05_wf2_Route/B05_wf2_Route_Schema.py index eb24e675..985594de 100644 --- a/B05_wf2_Route/B05_wf2_Route_Schema.py +++ b/B05_wf2_Route/B05_wf2_Route_Schema.py @@ -100,8 +100,9 @@ class RouteSolveResponse(BaseModel): metrics: dict[str, Any] required_points_ok: bool route_data_path: str - longitudinal_length_m: float - cross_section_count: int + # 종횡단 생성 실패 시 None (경로 자체는 저장됨) + longitudinal_length_m: float | None = None + cross_section_count: int | None = None class RouteConfirmResponse(BaseModel): diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index c146f1d9..509c2d4a 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -97,6 +97,7 @@ export async function renderB05Route(root: HTMLElement): Promise { const profilePanel = createRouteProfilePanel(); let confirmedSurface: SurfaceModelSummary | null = null; let latest: RouteLatestResponse | null = null; + let defaultCrossHalfWidth = 0; let routeReady = false; let stale = false; let restoring = true; @@ -154,7 +155,10 @@ export async function renderB05Route(root: HTMLElement): Promise { function renderSections(detail: SectionDetailResponse): void { profilePanel.render(detail); - viewer.renderStationLines(detail.longitudinal.stations, panel.values().crossHalfWidth); + viewer.renderStationLines( + detail.longitudinal.stations, + panel.values().crossHalfWidth ?? defaultCrossHalfWidth, + ); } async function restoreSections(routeId: number): Promise { @@ -239,8 +243,12 @@ export async function renderB05Route(root: HTMLElement): Promise { long_sample_interval_m: values.longSampleInterval, }); renderLatest(await fetchLatestRoute(activeProjectId)); - renderSections(await fetchSectionDetail(activeProjectId, solved.route_id)); - showToast("최적 경로 계산이 완료되었습니다.", "success"); + await restoreSections(solved.route_id); + if (solved.cross_section_count === null) { + showToast("경로는 저장되었지만 종횡단 생성에 실패했습니다.", "error"); + } else { + showToast("최적 경로 계산이 완료되었습니다.", "success"); + } } catch (error) { showToast(error instanceof Error ? error.message : "경로 계산에 실패했습니다.", "error"); } finally { @@ -270,6 +278,7 @@ export async function renderB05Route(root: HTMLElement): Promise { fetchSectionContext(activeProjectId), ]); confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null; + defaultCrossHalfWidth = sectionContext.defaults.cross_half_width_m; if (!confirmedSurface) { showToast("확정된 지표면 모델이 없습니다.", "error"); } else { diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts index 3b45391f..d88435e5 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts @@ -12,10 +12,10 @@ export interface RoutePanelValues { minUphillGrade: number | null; minDownhillGrade: number | null; allowAvoidPassThrough: boolean; - stationInterval: number; - crossHalfWidth: number; - crossSampleInterval: number; - longSampleInterval: number; + stationInterval: number | null; + crossHalfWidth: number | null; + crossSampleInterval: number | null; + longSampleInterval: number | null; } interface PanelCallbacks { @@ -275,10 +275,10 @@ export function createRoutePanel(callbacks: PanelCallbacks) { minUphillGrade: parseOptional(minUphillGrade), minDownhillGrade: parseOptional(minDownhillGrade), allowAvoidPassThrough: avoidPass.checked, - stationInterval: Number(stationInterval.value), - crossHalfWidth: Number(crossHalfWidth.value), - crossSampleInterval: Number(crossSampleInterval.value), - longSampleInterval: Number(longSampleInterval.value), + stationInterval: parseOptional(stationInterval), + crossHalfWidth: parseOptional(crossHalfWidth), + crossSampleInterval: parseOptional(crossSampleInterval), + longSampleInterval: parseOptional(longSampleInterval), }; }, restore(values: Partial) {