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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user