diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py b/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py index 6c53a932f..e40143ccd 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py @@ -93,7 +93,9 @@ def run_section_generation( "invalid_samples": result["summary"]["invalid_longitudinal_samples"], } - # 측점별 횡단면 저장 + # 측점별 횡단면 저장 (detail 조회가 폴더 전체를 glob하므로 이전 실행 잔재를 먼저 비운다) + for stale_file in cross_dir.glob("cross_*.json"): + stale_file.unlink() cross_records: list[dict[str, Any]] = [] for seq, cross_section in enumerate(result["cross_sections"]): chainage = float(cross_section["chainage_m"]) diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts index 9ef8baa0b..1409aa424 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts @@ -139,7 +139,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) { ); const contour = section("등고선 간격"); - const contourInterval = numberField("간격 (m)", "1"); + const contourInterval = numberField("간격 (m), 최소 0.5m", "1"); const contourRow = document.createElement("div"); contourRow.className = "b05-route__contour-row"; contourRow.append( diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts index 2c7498626..70c996388 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts @@ -6,6 +6,7 @@ * GET /api/projects/{project_id}/sections/context → 확정 경로 + 기본 옵션 * GET /api/projects/{project_id}/sections/{route_id} → 종단 요약 조회 * GET /api/projects/{project_id}/sections/{route_id}/detail → 종횡단 원시 샘플 조회 + * POST /api/projects/{project_id}/sections/{route_id}/regenerate → 횡단 반폭 재생성 * POST /api/projects/{project_id}/sections/{route_id}/confirm → 종횡단 확정 * * 규칙: @@ -137,6 +138,18 @@ export async function fetchSectionDetail( }); } +/** 횡단 반폭을 반영해 종횡단을 재생성·저장하고 갱신된 상세를 반환한다. */ +export async function regenerateSections( + projectId: string, + routeId: number, + crossHalfWidthM: number, +): Promise { + return requestJson( + `/projects/${projectId}/sections/${routeId}/regenerate`, + { method: "POST", body: JSON.stringify({ cross_half_width_m: crossHalfWidthM }) }, + ); +} + /** 경로의 종·횡단면을 확정한다. */ export async function confirmSections( projectId: string, diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py index 587799a42..3b523c7d6 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py @@ -61,6 +61,38 @@ async def get_confirmed_route_context( } +async def get_route_generation_source( + connection: aiomysql.Connection, project_id: UUID, route_id: int +) -> dict[str, Any] | None: + """종횡단 재생성에 필요한 경로 GeoJSON 경로와 좌표계를 route_id로 조회한다.""" + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """ + SELECT r.route_data_path, + COALESCE( + sm.crs_epsg, + (SELECT f.crs_epsg + FROM input_files f + WHERE f.project_id = r.project_id AND f.crs_epsg IS NOT NULL + ORDER BY f.id DESC + LIMIT 1) + ) AS crs_epsg + FROM routes r + LEFT JOIN surface_models sm ON sm.id = r.surface_model_id + WHERE r.id = %s AND r.project_id = %s + LIMIT 1 + """, + (route_id, str(project_id)), + ) + row = await cursor.fetchone() + if not row or not row["route_data_path"]: + return None + return { + "route_data_path": str(row["route_data_path"]), + "crs_epsg": int(row["crs_epsg"]) if row["crs_epsg"] is not None else None, + } + + async def delete_sections_for_route(connection: aiomysql.Connection, route_id: int) -> None: """경로 재생성 전에 기존 종횡단 레코드를 삭제한다 (멱등 재실행).""" async with connection.cursor() as cursor: diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py index cee36fae8..299acab54 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py @@ -4,29 +4,37 @@ import asyncio import json import logging from pathlib import Path +from typing import Any from uuid import UUID +import aiomysql from fastapi import APIRouter from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B05_wf2_Route.B05_wf2_Route_Engine_Sections import run_section_generation from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import SectionGenerationOptions from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import ( confirm_sections_for_route, count_cross_sections, + create_longitudinal_section, + delete_sections_for_route, get_confirmed_route_context, get_longitudinal_section, + get_route_generation_source, + insert_cross_sections, ) from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import ( SectionConfirmResponse, SectionContextResponse, SectionDetailResponse, SectionOptionDefaults, + SectionRegenerateRequest, SectionSummaryResponse, ) from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import get_surface_confirmation_params -from common_util.common_util_workflow_state import complete_stage +from common_util.common_util_workflow_state import complete_stage, get_workflow_state from config.config_db import get_db_pool from config.config_system import FOREST_ROAD_MIN_WIDTH_M, SECTION_VERTICAL_EXAGGERATION @@ -169,6 +177,96 @@ async def get_section_detail( ) +def _regeneration_options( + stage_params: dict[str, Any] | None, cross_half_width_m: float +) -> SectionGenerationOptions: + """solve 시점의 stage 2 측점 옵션을 유지하고 횡단 반폭만 교체한다.""" + defaults = SectionGenerationOptions() + params = stage_params or {} + return SectionGenerationOptions( + station_interval_m=params.get("station_interval_m") or defaults.station_interval_m, + cross_half_width_m=cross_half_width_m, + cross_sample_interval_m=params.get("cross_sample_interval_m") + or defaults.cross_sample_interval_m, + long_sample_interval_m=params.get("long_sample_interval_m") + or defaults.long_sample_interval_m, + include_endpoint=defaults.include_endpoint, + ) + + +@router.post("/{project_id}/sections/{route_id}/regenerate", response_model=SectionDetailResponse) +async def regenerate_sections( + project_id: UUID, route_id: int, request: SectionRegenerateRequest +) -> SectionDetailResponse | JSONResponse: + """표시 옵션의 횡단 반폭으로 종횡단을 재생성해 저장하고 상세를 반환한다.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + source = await get_route_generation_source(connection, project_id, route_id) + if not source: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "재생성할 경로가 없습니다."}, + ) + surface_params = await get_surface_confirmation_params(connection, str(project_id)) + stored_path = await get_project_storage_relative_path(connection, project_id) + async with connection.cursor(aiomysql.DictCursor) as cursor: + workflow = await get_workflow_state(cursor, str(project_id)) + route_stage = next( + (stage for stage in workflow["stages"] if stage["stage_no"] == 2), + None, + ) + project_root = Path(resolve_stored_project_path(stored_path)) + crs_epsg = source["crs_epsg"] + sections = await asyncio.to_thread( + run_section_generation, + project_root, + source["route_data_path"], + surface_params["source_filter"], + surface_params["method"], + bool(surface_params["smooth"]), + options=_regeneration_options( + route_stage.get("params") if route_stage else None, + request.cross_half_width_m, + ), + crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None, + ) + 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 + result = sections["result"] + return SectionDetailResponse( + longitudinal=result["longitudinal"], cross_sections=result["cross_sections"] + ) + except FileNotFoundError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except ValueError as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B06 종횡단 재생성 실패: project_id=%s route_id=%s", project_id, route_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "종횡단 재생성 처리 중 오류가 발생했습니다."}, + ) + + @router.post("/{project_id}/sections/{route_id}/confirm", response_model=SectionConfirmResponse) async def confirm_sections( project_id: UUID, route_id: int diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py index 2da305485..974cdd347 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py @@ -2,7 +2,13 @@ from typing import Any -from pydantic import BaseModel +from pydantic import BaseModel, Field + + +class SectionRegenerateRequest(BaseModel): + """표시 옵션의 횡단 반폭 변경에 따른 종횡단 재생성 요청.""" + + cross_half_width_m: float = Field(..., gt=0) class SectionConfirmResponse(BaseModel): diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index df6029e1d..58dbd92a4 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -20,6 +20,7 @@ import { fetchSectionContext, fetchSectionDetail, getSections, + regenerateSections, type SectionContextResponse, type SectionDetailResponse, type SectionSummaryResponse, @@ -154,8 +155,26 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { sectionView.render(sectionDetail, verticalExaggeration(), crossHalfWidth(), stationInterval); } + async function applyCrossHalfWidth(): Promise { + const width = crossHalfWidth(); + if (!projectId || currentRouteId === null || width === undefined) { + renderSectionDetail(); + return; + } + showLoadingOverlay(); + try { + sectionDetail = await regenerateSections(projectId, currentRouteId, width); + renderSectionDetail(); + } catch (error) { + const detail = error instanceof Error ? ` ${error.message}` : ""; + showToast(`${L("B06_Profile_Regenerate_Failed")}${detail}`, "error"); + } finally { + hideLoadingOverlay(); + } + } + verticalExaggerationField.input.addEventListener("input", renderSectionDetail); - crossHalfWidthField.input.addEventListener("input", renderSectionDetail); + crossHalfWidthField.input.addEventListener("change", () => void applyCrossHalfWidth()); async function confirmCurrentSections(): Promise { if (!projectId || currentRouteId === null) return; diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 782de85bf..01b214774 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -764,6 +764,10 @@ export const ui_locales = { "종·횡단 도면 데이터를 불러오지 못했습니다.", "Failed to load section drawing data.", ], + B06_Profile_Regenerate_Failed: [ + "횡단 반폭 재생성에 실패했습니다.", + "Failed to regenerate sections with the new half-width.", + ], B06_Profile_View_Longitudinal: ["종단면도", "Longitudinal profile"], B06_Profile_View_Cross: ["횡단면도", "Cross sections"], B06_Profile_View_StationCount: ["횡단 측점", "Cross stations"],