This commit is contained in:
2026-07-19 09:46:49 +09:00
parent 3250c3bb65
commit 4061c77cbe
8 changed files with 179 additions and 5 deletions
@@ -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"])
+1 -1
View File
@@ -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(
@@ -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<SectionDetailResponse> {
return requestJson<SectionDetailResponse>(
`/projects/${projectId}/sections/${routeId}/regenerate`,
{ method: "POST", body: JSON.stringify({ cross_half_width_m: crossHalfWidthM }) },
);
}
/** 경로의 종·횡단면을 확정한다. */
export async function confirmSections(
projectId: string,
@@ -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:
@@ -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
@@ -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):
@@ -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<void> {
sectionView.render(sectionDetail, verticalExaggeration(), crossHalfWidth(), stationInterval);
}
async function applyCrossHalfWidth(): Promise<void> {
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<void> {
if (!projectId || currentRouteId === null) return;
+4
View File
@@ -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"],