This commit is contained in:
2026-07-19 10:45:22 +09:00
parent 4061c77cbe
commit 52778784de
10 changed files with 204 additions and 29 deletions
+11
View File
@@ -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<RouteConfirmResponse> {
return requestJson<RouteConfirmResponse>(`/projects/${projectId}/route/confirm`, {
@@ -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하므로 이전 실행 잔재를 먼저 비운다)
+57 -9
View File
@@ -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 입력 스냅샷을 반환한다."""
+16
View File
@@ -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):
"""경로 탐색 실행 결과."""
+2
View File
@@ -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<void> {
showLoadingOverlay();
try {
await viewer.reloadContours(interval);
await updateContourInterval(activeProjectId, interval);
} catch (error) {
showToast(error instanceof Error ? error.message : "등고선 조회에 실패했습니다.", "error");
} finally {
@@ -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:
@@ -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,
),
@@ -106,6 +106,12 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
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<void> {
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<void> {
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<void> {
async function applyCrossHalfWidth(): Promise<void> {
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<void> {
if (!projectId || currentRouteId === null) return;
@@ -251,15 +267,26 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
}
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"));
@@ -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,
+5
View File
@@ -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"],