feat(B05/B06): 계획선 편집 시 횡단 설계 일괄 재계산 파이프라인

B05에서 계획고를 끌어도 횡단 기준 유토곡선이 그대로였던 원인은 횡단 설계가 옛
계획고 기준으로 남아 있었기 때문이다. 공유 캐시(4차)는 B06→B05 방향만 해결했고,
B05에는 B06의 reconcileStaleDesigns에 해당하는 재계산 트리거가 없었다.

- POST /sections/{route_id}/cross-design/preview 신설: 계획선 편집 델타를 받아
  계획선을 재구성하고 전 측점 횡단 설계를 한 번에 다시 계산해 상세를 돌려준다.
  저장하지 않으며 영속화는 각 페이지의 임시저장·확정이 맡는다. 측점마다 따로
  부르면 수십 번 왕복하므로 일괄 계산으로 뒀다.
- 재계산 시 사용자가 이미 고른 지반유형·단면유형·측구·암 경계는 그대로 두고
  계획고만 새 선형 값으로 교체한다. 지정 없는 측점은 기본값(리핑암 + 0.5m).
- B05 rebuild()에 프리뷰 호출 결선: 400ms 디바운스로 길게 누르기 대응, 요청 번호
  비교로 늦게 온 응답 폐기. 응답은 공유 캐시의 같은 객체를 제자리 갱신하므로
  B06으로 넘어가도 다시 받을 필요가 없다.

검증: 100m에 계획고 +3m 편집 시 계획고 533.2→536.14m, 횡단 절토면적 합
18.6→15.8㎡, 성토면적 합 22.0→66.2㎡로 함께 이동. 지반유형 유지 확인.
typecheck·vite build·ruff·B03 테스트 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 20:41:04 +09:00
co-authored by Claude Opus 5
parent d21b0ec51d
commit cffd65bd75
4 changed files with 196 additions and 11 deletions
@@ -32,6 +32,7 @@ import {
import { createProgressCircle } from "@ui/ui_template_progress";
import { showToast } from "@ui/ui_template_elements";
import { saveProfileAlignment } from "./B05_wf2_Route_Api_Fetch";
import { previewCrossDesigns } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch";
import type {
AlignmentBase,
AlignmentEdits,
@@ -88,6 +89,9 @@ const MIN_PANEL_HEIGHT = 180;
const MAX_PANEL_HEIGHT_RATIO = 0.9;
/** 테이블 12행. 행 높이와 셀 폭에서 글자 크기를 정하는 데 쓴다. */
const TABLE_ROW_COUNT = 12;
/** 계획고 편집 후 횡단 재계산을 서버에 묻기까지 기다리는 시간(ms).
* ▲/▼ 길게 누르기(초당 10회)로 요청이 쏟아지지 않게 마지막 값만 보낸다. */
const CROSS_PREVIEW_DEBOUNCE_MS = 400;
/**
* 저장된 계획선 선형을 읽되 **모양을 먼저 검증한다**.
@@ -306,6 +310,9 @@ export function createRouteProfilePanel(
let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
let resizeTimer = 0;
let redrawPending = false;
/** 횡단 설계 프리뷰 디바운스 타이머와 최신 요청 번호(늦게 온 응답 버리기용). */
let crossPreviewTimer = 0;
let crossPreviewSeq = 0;
let lastWidth = 0;
let lastHeight = 0;
/** 처음 그릴 때 잡힌 도면 테이블 높이(px). 패널을 끌어도 이 값을 지킨다. */
@@ -398,6 +405,10 @@ export function createRouteProfilePanel(
*/
function rebuild(): void {
if (base) alignment = buildAlignment(base, store.edits());
// 계획고가 바뀌면 측점별 횡단 단면적도 함께 바뀐다 — 서버에 한 번 물어 전 측점을
// 다시 계산해 공유 캐시에 얹는다. 그래야 **횡단 기준** 유토곡선이 따라 움직인다
// (2026-08-03 사용자 보고: B05에서 계획선을 끌어도 횡단 곡선이 그대로였음).
scheduleCrossPreview();
if (redrawPending) return;
redrawPending = true;
requestAnimationFrame(() => {
@@ -406,6 +417,32 @@ export function createRouteProfilePanel(
});
}
/**
* 횡단 설계 프리뷰 요청 — 끌기 중에는 계속 호출되므로 마지막 값만 보낸다.
* 응답이 늦게 와도 그 사이 편집이 더 있었으면 버린다(seq 비교).
*/
function scheduleCrossPreview(): void {
if (!detail || routeId === null) return;
window.clearTimeout(crossPreviewTimer);
crossPreviewTimer = window.setTimeout(() => {
if (!detail || routeId === null) return;
const seq = (crossPreviewSeq += 1);
const targetRouteId = routeId;
void previewCrossDesigns(projectId, targetRouteId, store.edits())
.then((next) => {
if (seq !== crossPreviewSeq || !detail || routeId !== targetRouteId) return;
// 공유 캐시가 들고 있는 **같은 객체**를 제자리 갱신한다 — B06이 이 객체를 그대로
// 보므로 페이지를 넘어가도 다시 받을 필요가 없다.
detail.cross_sections = next.cross_sections;
detail.longitudinal.design_profiles = next.longitudinal.design_profiles;
draw();
})
.catch(() => {
/* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */
});
}, CROSS_PREVIEW_DEBOUNCE_MS);
}
function draw(): void {
if (!detail || body.clientWidth <= 0 || body.clientHeight <= 0) return;
lastWidth = body.clientWidth;
@@ -779,6 +816,7 @@ export function createRouteProfilePanel(
},
dispose() {
window.clearTimeout(resizeTimer);
window.clearTimeout(crossPreviewTimer);
resizeObserver.disconnect();
heightResizer.dispose();
},
@@ -450,3 +450,31 @@ export async function getCompanyStandard(
{ method: "GET" },
);
}
/** 계획선 편집 델타(자동 선형 대비 계획고 델타 + 종단곡선 반경). B05 편집 스토어와 같은 모양. */
export interface ProfileAlignmentEdits {
station_offsets: Record<string, number>;
curve_radii: Record<string, number>;
}
/**
* 계획선 편집을 반영해 **계획선 + 전 측점 횡단 설계**를 다시 계산해 받는다(서버 저장 없음).
*
* B05에서 계획고를 끄는 동안 횡단 단면적이 함께 움직여야 횡단 기준 유토곡선이 따라온다.
* 측점마다 따로 부르면 수십 번 왕복하므로 한 번에 계산한다. 영속화는 각 페이지의
* 임시저장·확정이 맡는다.
*/
export async function previewCrossDesigns(
projectId: string,
routeId: number,
edits: ProfileAlignmentEdits,
standardCrossSection?: StandardCrossSection,
): Promise<SectionDetailResponse> {
return requestJson<SectionDetailResponse>(
`/projects/${projectId}/sections/${routeId}/cross-design/preview`,
{
method: "POST",
body: JSON.stringify({ ...edits, standard_cross_section: standardCrossSection ?? null }),
},
);
}
@@ -8,46 +8,40 @@ from typing import Any
from uuid import UUID
import aiomysql
from fastapi import APIRouter, Body, Depends
from fastapi import APIRouter, Depends
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_Grade_Profile import rebuild_alignment_profile
from B05_wf2_Route.B05_wf2_Route_Engine_Sections import (
cross_filename,
prune_stale_cross_files,
run_section_generation,
)
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import SectionGenerationOptions
from B05_wf2_Route.B05_wf2_Route_Router_Confirm import _merge_uphill_overrides_into_longitudinal
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Design import compute_cross_design
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_cross_section_designs,
get_cross_sections_missing_design_chainages,
get_latest_section_options,
get_longitudinal_section,
get_project_standard_cross_section,
get_route_generation_source,
insert_cross_sections,
list_recent_company_projects,
merge_cross_section_design_patch,
merge_longitudinal_section_data,
merge_longitudinal_section_options,
update_cross_section_design,
)
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import (
CompanyStandardListResponse,
CompanyStandardProject,
CompanyStandardResponse,
CrossDesignPreviewRequest,
CrossDesignRequest,
CrossDesignResponse,
HaulEquipmentLimit,
SectionConfirmRequest,
SectionConfirmResponse,
SectionContextResponse,
SectionDetailResponse,
SectionOptionDefaults,
@@ -58,7 +52,7 @@ from common_util.common_util_auth import verify_session
from common_util.common_util_route_profile import design_elevation_from_longitudinal
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, get_workflow_state
from common_util.common_util_workflow_state import get_workflow_state
from config.config_db import get_db_pool
from config.config_system import (
EARTHWORK_CONVERSION_FACTORS,
@@ -492,6 +486,111 @@ def _attach_default_designs(
continue
def _recompute_designs_for_alignment(
longitudinal: dict[str, Any],
cross_sections: list[dict[str, Any]],
stored_designs: list[dict[str, Any]],
standard: dict[str, Any] | None,
) -> None:
"""새 계획선 기준으로 전 측점 횡단 설계를 다시 계산해 얹는다(메모리 프리뷰).
사용자가 이미 고른 지반유형·단면유형·측구·암 경계는 **그대로 유지**하고 계획고만
새 선형 값으로 바꿔 넣는다 — 계획선을 끌었다고 측점 선택이 초기화되면 안 된다.
지정이 없는 측점은 기본값(리핑암 + 암반 경계 0.5m + 상단측 절토)으로 계산한다.
"""
default_modes = _default_section_modes(longitudinal)
pavement = _pavement_suggestions(longitudinal)
stored_by_chainage = {
round(float(record["chainage_m"]), 3): (record.get("design") or {})
for record in stored_designs
}
for section in cross_sections:
chainage_m = float(section.get("chainage_m", 0.0))
key = round(chainage_m, 3)
stored = stored_by_chainage.get(key) or {}
suggested = pavement.get(key, False)
try:
design = compute_cross_design(
section.get("samples", []),
design_elevation_from_longitudinal(longitudinal, chainage_m),
ground_type=str(stored.get("ground_type") or "ripping_rock"),
section_mode=str(stored.get("section_mode") or default_modes.get(key, "left_cut")),
ditch_side=stored.get("ditch_side"),
ditch_type=str(stored.get("ditch_type") or "standard"),
paved=bool(stored.get("paved", suggested)),
standard=standard,
rock_boundary_offset_m=stored.get(
"rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
),
two_stage_slope=bool(stored.get("two_stage_slope", True)),
ditch_enabled=stored.get("ditch_enabled"),
)
except (ValueError, KeyError):
continue
design["status"] = "provisional"
design["pavement_suggested"] = suggested
section["design"] = design
@router.post(
"/{project_id}/sections/{route_id}/cross-design/preview",
response_model=SectionDetailResponse,
)
async def preview_cross_designs(
project_id: UUID, route_id: int, request: CrossDesignPreviewRequest
) -> SectionDetailResponse | JSONResponse:
"""계획선 편집 델타로 계획선과 전 측점 횡단 설계를 다시 계산해 돌려준다(저장 없음).
B05에서 계획고를 끄는 동안 횡단 단면적·유토곡선이 함께 움직이게 하는 프리뷰 경로다.
영속화는 각 페이지의 임시저장·확정이 맡는다.
"""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
longitudinal_row = await get_longitudinal_section(connection, project_id, route_id)
if not longitudinal_row:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "종횡단 상세 결과가 없습니다."},
)
stored_path = await get_project_storage_relative_path(connection, project_id)
designs = await get_cross_section_designs(connection, route_id)
project_root = Path(resolve_stored_project_path(stored_path))
detail = await asyncio.to_thread(
_read_section_detail,
project_root,
str(longitudinal_row["longitudinal_file_path"]),
)
def rebuild() -> None:
alignment, profile = rebuild_alignment_profile(detail["longitudinal"], request.edits())
detail["longitudinal"]["profile_alignment"] = alignment
detail["longitudinal"]["design_profiles"] = [profile]
_recompute_designs_for_alignment(
detail["longitudinal"],
detail["cross_sections"],
designs,
request.standard_cross_section,
)
await asyncio.to_thread(rebuild)
return SectionDetailResponse(
**detail, balloon_offsets=_read_balloon_offsets(longitudinal_row)
)
except FileNotFoundError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except (OSError, ValueError, json.JSONDecodeError) 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": "횡단 설계 프리뷰 계산 중 오류가 발생했습니다."},
)
def _compute_default_designs(
project_root: Path,
longitudinal_file_path: str,
@@ -2,7 +2,7 @@
from typing import Any, Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
class SectionRegenerateRequest(BaseModel):
@@ -171,3 +171,23 @@ class SectionDetailResponse(BaseModel):
# longitudinal_sections.data.mass_haul.balloon_offsets에 확정 시점에 저장된 값이며,
# 브라우저가 바뀌어도 같은 자리에 뜨도록 진입 시 프론트 캐시의 씨앗으로 내려보낸다.
balloon_offsets: dict[str, list[float]] | None = None
class CrossDesignPreviewRequest(BaseModel):
"""계획선 편집 델타로 **전 측점 횡단 설계를 다시 계산**해 달라는 요청(저장 없음).
B05에서 계획고를 끌면 측점별 횡단 단면적이 함께 바뀌어야 횡단 기준 유토곡선이
따라 움직인다. 측점마다 따로 부르면 수십 번 왕복하므로 한 번에 계산한다.
사용자가 이미 지정한 지반유형·단면유형·측구·암 경계는 그대로 유지하고
**계획고만 새 선형으로 바꿔** 재계산한다.
"""
model_config = ConfigDict(extra="forbid")
station_offsets: dict[str, float] = Field(default_factory=dict)
curve_radii: dict[str, float] = Field(default_factory=dict)
# B06 설정 패널 편집값(있으면 그 표준단면으로 계산).
standard_cross_section: dict[str, Any] | None = None
def edits(self) -> dict[str, Any]:
return {"station_offsets": self.station_offsets, "curve_radii": self.curve_radii}