diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index b33fda70..c3adb5a7 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -145,6 +145,17 @@ export async function solveRoute( }); } +/** 등고선 간격 재적용 값을 서버(stage 1 params)에 영속화한다. */ +export async function updateContourInterval( + projectId: string, + contourIntervalM: number, +): Promise<{ status: string; contour_interval_m: number }> { + return requestJson<{ status: string; contour_interval_m: number }>( + `/projects/${projectId}/route/contour-interval`, + { method: "PUT", body: JSON.stringify({ contour_interval_m: contourIntervalM }) }, + ); +} + /** 프로젝트의 최신 경로를 확정한다. */ export async function confirmRoute(projectId: string): Promise { return requestJson(`/projects/${projectId}/route/confirm`, { diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py b/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py index e40143cc..f94278a2 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py @@ -91,6 +91,8 @@ def run_section_generation( "length_m": result["longitudinal"]["length_m"], "station_count": result["summary"]["station_count"], "invalid_samples": result["summary"]["invalid_longitudinal_samples"], + # 사용자 선택값의 단일 소스(DB): 재생성·재탐색 시 이 값을 우선 사용한다. + "options": result["options"], } # 측점별 횡단면 저장 (detail 조회가 폴더 전체를 glob하므로 이전 실행 잔재를 먼저 비운다) diff --git a/B05_wf2_Route/B05_wf2_Route_Router.py b/B05_wf2_Route/B05_wf2_Route_Router.py index 2da7ea5f..7e487a26 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router.py +++ b/B05_wf2_Route/B05_wf2_Route_Router.py @@ -3,6 +3,7 @@ import asyncio import logging from pathlib import Path +from typing import Any from uuid import UUID import aiomysql @@ -24,6 +25,8 @@ from B05_wf2_Route.B05_wf2_Route_Repository import ( insert_route_points, ) from B05_wf2_Route.B05_wf2_Route_Schema import ( + ContourIntervalUpdateRequest, + ContourIntervalUpdateResponse, RouteConfirmResponse, RouteLatestResponse, RouteSolveRequest, @@ -32,10 +35,14 @@ from B05_wf2_Route.B05_wf2_Route_Schema import ( from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import ( create_longitudinal_section, delete_sections_for_route, + get_latest_section_options, insert_cross_sections, ) 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_surface_confirmation import ( + get_surface_confirmation_params, + update_contour_interval_param, +) from common_util.common_util_workflow_state import ( complete_stage, fail_stage, @@ -48,15 +55,25 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B05 Route Design"]) -def _section_options(request: RouteSolveRequest) -> SectionGenerationOptions: +def _section_options( + request: RouteSolveRequest, stored_options: dict[str, Any] | None +) -> SectionGenerationOptions: + """요청 값 → DB 저장 옵션(단일 소스) → config 기본값 순으로 결정한다.""" defaults = SectionGenerationOptions() + stored = stored_options or {} return SectionGenerationOptions( - station_interval_m=request.station_interval_m or defaults.station_interval_m, - cross_half_width_m=request.cross_half_width_m or defaults.cross_half_width_m, - cross_sample_interval_m=( - request.cross_sample_interval_m or defaults.cross_sample_interval_m - ), - long_sample_interval_m=request.long_sample_interval_m or defaults.long_sample_interval_m, + station_interval_m=request.station_interval_m + or stored.get("station_interval_m") + or defaults.station_interval_m, + cross_half_width_m=request.cross_half_width_m + or stored.get("cross_half_width_m") + or defaults.cross_half_width_m, + cross_sample_interval_m=request.cross_sample_interval_m + or stored.get("cross_sample_interval_m") + or defaults.cross_sample_interval_m, + long_sample_interval_m=request.long_sample_interval_m + or stored.get("long_sample_interval_m") + or defaults.long_sample_interval_m, include_endpoint=defaults.include_endpoint, ) @@ -206,6 +223,7 @@ async def solve_route( crs_epsg = await get_surface_crs_epsg( connection, project_id, request.surface_model_id ) + stored_options = await get_latest_section_options(connection, project_id) sections = await asyncio.to_thread( run_section_generation, project_root, @@ -213,7 +231,7 @@ async def solve_route( request.filter_key, request.method, request.smooth, - options=_section_options(request), + options=_section_options(request, stored_options), crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None, ) await connection.begin() @@ -281,6 +299,36 @@ async def solve_route( ) +@router.put("/{project_id}/route/contour-interval", response_model=ContourIntervalUpdateResponse) +async def update_contour_interval( + project_id: UUID, request: ContourIntervalUpdateRequest +) -> ContourIntervalUpdateResponse | JSONResponse: + """B05 등고선 간격 재적용 값을 stage 1 params(단일 소스)에 영속화한다.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + await connection.begin() + try: + await update_contour_interval_param( + connection, str(project_id), request.contour_interval_m + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + return ContourIntervalUpdateResponse( + project_id=str(project_id), contour_interval_m=request.contour_interval_m + ) + except LookupError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B05 등고선 간격 저장 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "등고선 간격 저장 중 오류가 발생했습니다."}, + ) + + @router.get("/{project_id}/route/latest", response_model=RouteLatestResponse) async def read_latest_route(project_id: UUID) -> RouteLatestResponse | JSONResponse: """최신 경로와 DB 렌더 좌표, WF1/WF2 입력 스냅샷을 반환한다.""" diff --git a/B05_wf2_Route/B05_wf2_Route_Schema.py b/B05_wf2_Route/B05_wf2_Route_Schema.py index 985594de..baf7652c 100644 --- a/B05_wf2_Route/B05_wf2_Route_Schema.py +++ b/B05_wf2_Route/B05_wf2_Route_Schema.py @@ -90,6 +90,22 @@ class RouteSolveRequest(BaseModel): } +class ContourIntervalUpdateRequest(BaseModel): + """등고선 간격 재적용 영속화 요청.""" + + model_config = ConfigDict(extra="forbid") + + contour_interval_m: float = Field(gt=0) + + +class ContourIntervalUpdateResponse(BaseModel): + """등고선 간격 영속화 결과.""" + + status: str = "success" + project_id: str + contour_interval_m: float + + class RouteSolveResponse(BaseModel): """경로 탐색 실행 결과.""" diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index 45174f42..315b6efa 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -16,6 +16,7 @@ import { confirmRoute, fetchLatestRoute, solveRoute, + updateContourInterval, type CirclePoint, type RouteLatestResponse, type RoutePoint, @@ -223,6 +224,7 @@ export async function renderB05Route(root: HTMLElement): Promise { showLoadingOverlay(); try { await viewer.reloadContours(interval); + await updateContourInterval(activeProjectId, interval); } catch (error) { showToast(error instanceof Error ? error.message : "등고선 조회에 실패했습니다.", "error"); } finally { diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py index 3b523c7d..fe04565c 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py @@ -61,6 +61,31 @@ async def get_confirmed_route_context( } +async def get_latest_section_options( + connection: aiomysql.Connection, project_id: UUID +) -> dict[str, Any] | None: + """프로젝트 최신 종단면 data에 저장된 생성 옵션 스냅샷을 반환한다 (없으면 None).""" + async with connection.cursor() as cursor: + await cursor.execute( + """ + SELECT data + FROM longitudinal_sections + WHERE project_id = %s + ORDER BY id DESC + LIMIT 1 + """, + (str(project_id),), + ) + row = await cursor.fetchone() + if not row or not row[0]: + return None + data = row[0] + if isinstance(data, str): + data = json.loads(data) + options = data.get("options") if isinstance(data, dict) else None + return options if isinstance(options, dict) else None + + async def get_route_generation_source( connection: aiomysql.Connection, project_id: UUID, route_id: int ) -> dict[str, Any] | None: diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py index 299acab5..46535c22 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py @@ -20,6 +20,7 @@ from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import ( create_longitudinal_section, delete_sections_for_route, get_confirmed_route_context, + get_latest_section_options, get_longitudinal_section, get_route_generation_source, insert_cross_sections, @@ -178,18 +179,23 @@ async def get_section_detail( def _regeneration_options( - stage_params: dict[str, Any] | None, cross_half_width_m: float + stored_options: dict[str, Any] | None, + stage_params: dict[str, Any] | None, + cross_half_width_m: float, ) -> SectionGenerationOptions: - """solve 시점의 stage 2 측점 옵션을 유지하고 횡단 반폭만 교체한다.""" + """DB 저장 옵션(단일 소스) → stage 2 params → config 순으로 유지하고 반폭만 교체한다.""" defaults = SectionGenerationOptions() + stored = stored_options or {} params = stage_params or {} + + def pick(key: str, default: float) -> float: + return stored.get(key) or params.get(key) or default + return SectionGenerationOptions( - station_interval_m=params.get("station_interval_m") or defaults.station_interval_m, + station_interval_m=pick("station_interval_m", 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, + cross_sample_interval_m=pick("cross_sample_interval_m", defaults.cross_sample_interval_m), + long_sample_interval_m=pick("long_sample_interval_m", defaults.long_sample_interval_m), include_endpoint=defaults.include_endpoint, ) @@ -210,6 +216,7 @@ async def regenerate_sections( ) surface_params = await get_surface_confirmation_params(connection, str(project_id)) stored_path = await get_project_storage_relative_path(connection, project_id) + stored_options = await get_latest_section_options(connection, project_id) async with connection.cursor(aiomysql.DictCursor) as cursor: workflow = await get_workflow_state(cursor, str(project_id)) route_stage = next( @@ -226,6 +233,7 @@ async def regenerate_sections( surface_params["method"], bool(surface_params["smooth"]), options=_regeneration_options( + stored_options, route_stage.get("params") if route_stage else None, request.cross_half_width_m, ), diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index 58dbd92a..0bd22569 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -106,6 +106,12 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { crossHalfWidthField.input.step = "0.1"; displayGroup.append(crossHalfWidthField.root, verticalExaggerationField.root); + const recalcButton = createButton({ + label: L("B06_Profile_Btn_Recalc"), + variant: "ghost", + onClick: () => void applyCrossHalfWidth(), + }); + recalcButton.disabled = true; const confirmButton = createButton({ label: L("B06_Profile_Btn_Confirm"), variant: "filled", @@ -114,7 +120,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { confirmButton.disabled = true; const actionRow = document.createElement("div"); actionRow.className = "b06-profile__actions"; - actionRow.append(confirmButton); + actionRow.append(recalcButton, confirmButton); const leftForm = document.createElement("div"); leftForm.className = "b06-profile__form"; @@ -150,6 +156,16 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; } + let appliedHalfWidth: number | undefined; + + /** 반폭 미적용 상태에서는 [재계산]만 활성, 적용 완료 상태에서는 [확정]만 활성. */ + function updateActionState(): void { + const width = crossHalfWidth(); + const stale = sectionDetail !== null && width !== undefined && width !== appliedHalfWidth; + recalcButton.disabled = !stale; + confirmButton.disabled = sectionDetail === null || stale; + } + function renderSectionDetail(): void { if (sectionDetail) sectionView.render(sectionDetail, verticalExaggeration(), crossHalfWidth(), stationInterval); @@ -157,24 +173,24 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { async function applyCrossHalfWidth(): Promise { const width = crossHalfWidth(); - if (!projectId || currentRouteId === null || width === undefined) { - renderSectionDetail(); - return; - } + if (!projectId || currentRouteId === null || width === undefined) return; showLoadingOverlay(); try { sectionDetail = await regenerateSections(projectId, currentRouteId, width); + appliedHalfWidth = width; renderSectionDetail(); + showToast(L("B06_Profile_Regenerate_Success"), "success"); } catch (error) { const detail = error instanceof Error ? ` ${error.message}` : ""; showToast(`${L("B06_Profile_Regenerate_Failed")}${detail}`, "error"); } finally { hideLoadingOverlay(); + updateActionState(); } } verticalExaggerationField.input.addEventListener("input", renderSectionDetail); - crossHalfWidthField.input.addEventListener("change", () => void applyCrossHalfWidth()); + crossHalfWidthField.input.addEventListener("input", updateActionState); async function confirmCurrentSections(): Promise { if (!projectId || currentRouteId === null) return; @@ -251,15 +267,26 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { } renderSummary(existing); sectionDetail = await fetchSectionDetail(projectId, context.route_id); - const storedHalfWidth = Math.max( - 0, - ...sectionDetail.cross_sections.flatMap((section) => - section.samples.map((sample) => Math.abs(sample.offset_m ?? 0)), - ), - ); + // 단일 소스(DB data.options) 우선, options 스냅샷이 없는 과거 데이터는 샘플 최대 offset으로 추정 + const summaryData = existing.longitudinal.data as { + options?: { cross_half_width_m?: number; station_interval_m?: number }; + } | null; + const storedOptions = summaryData?.options; + const storedHalfWidth = + storedOptions?.cross_half_width_m && storedOptions.cross_half_width_m > 0 + ? storedOptions.cross_half_width_m + : Math.max( + 0, + ...sectionDetail.cross_sections.flatMap((section) => + section.samples.map((sample) => Math.abs(sample.offset_m ?? 0)), + ), + ); if (storedHalfWidth > 0) crossHalfWidthField.input.value = storedHalfWidth.toFixed(1); + if (storedOptions?.station_interval_m && storedOptions.station_interval_m > 0) + stationInterval = storedOptions.station_interval_m; + appliedHalfWidth = crossHalfWidth(); renderSectionDetail(); - confirmButton.disabled = false; + updateActionState(); } catch (error) { const detail = error instanceof Error ? ` ${error.message}` : ""; renderMessage(L("B06_Profile_Calculate_In_B05")); diff --git a/common_util/common_util_surface_confirmation.py b/common_util/common_util_surface_confirmation.py index d3a68753..193d4a38 100644 --- a/common_util/common_util_surface_confirmation.py +++ b/common_util/common_util_surface_confirmation.py @@ -63,6 +63,37 @@ async def get_surface_confirmation_params( return resolved +async def update_contour_interval_param( + connection: aiomysql.Connection, + project_id: str, + contour_interval_m: float, +) -> None: + """stage 1 params의 등고선 간격만 갱신한다 (B05 재적용 영속화).""" + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """ + SELECT params + FROM project_workflow_stages + WHERE project_id = %s AND stage_no = 1 + FOR UPDATE + """, + (project_id,), + ) + row = await cursor.fetchone() + if row is None: + raise LookupError("WF1 단계 상태를 찾을 수 없습니다.") + params = _decode_params(row.get("params") if row else None) + params["contour_interval_m"] = float(contour_interval_m) + await cursor.execute( + """ + UPDATE project_workflow_stages + SET params = %s + WHERE project_id = %s AND stage_no = 1 + """, + (json.dumps(params, ensure_ascii=False), project_id), + ) + + async def merge_surface_confirmation_params( connection: aiomysql.Connection, project_id: str, diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 01b21477..45bf8b49 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -768,6 +768,11 @@ export const ui_locales = { "횡단 반폭 재생성에 실패했습니다.", "Failed to regenerate sections with the new half-width.", ], + B06_Profile_Regenerate_Success: [ + "횡단 반폭을 반영해 종·횡단을 재생성했습니다.", + "Sections regenerated with the new half-width.", + ], + B06_Profile_Btn_Recalc: ["재계산", "Recalculate"], B06_Profile_View_Longitudinal: ["종단면도", "Longitudinal profile"], B06_Profile_View_Cross: ["횡단면도", "Cross sections"], B06_Profile_View_StationCount: ["횡단 측점", "Cross stations"],