260719_2
This commit is contained in:
@@ -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"])
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user