From cffd65bd75fcd3faa62184d138178075e57cf48c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 3 Aug 2026 20:41:04 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05/B06):=20=EA=B3=84=ED=9A=8D=EC=84=A0=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EC=8B=9C=20=ED=9A=A1=EB=8B=A8=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=20=EC=9D=BC=EA=B4=84=20=EC=9E=AC=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../B05_wf2_Route_UI_Profile_Panel.ts | 38 ++++++ .../B06_wf3_ProfileCross_Api_Fetch.ts | 28 +++++ .../B06_wf3_ProfileCross_Router.py | 119 ++++++++++++++++-- .../B06_wf3_ProfileCross_Schema.py | 22 +++- 4 files changed, 196 insertions(+), 11 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index 67ca5af8..1a01a8a1 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -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(); }, diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts index 919f7089..4e7744d7 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts @@ -450,3 +450,31 @@ export async function getCompanyStandard( { method: "GET" }, ); } + +/** 계획선 편집 델타(자동 선형 대비 계획고 델타 + 종단곡선 반경). B05 편집 스토어와 같은 모양. */ +export interface ProfileAlignmentEdits { + station_offsets: Record; + curve_radii: Record; +} + +/** + * 계획선 편집을 반영해 **계획선 + 전 측점 횡단 설계**를 다시 계산해 받는다(서버 저장 없음). + * + * B05에서 계획고를 끄는 동안 횡단 단면적이 함께 움직여야 횡단 기준 유토곡선이 따라온다. + * 측점마다 따로 부르면 수십 번 왕복하므로 한 번에 계산한다. 영속화는 각 페이지의 + * 임시저장·확정이 맡는다. + */ +export async function previewCrossDesigns( + projectId: string, + routeId: number, + edits: ProfileAlignmentEdits, + standardCrossSection?: StandardCrossSection, +): Promise { + return requestJson( + `/projects/${projectId}/sections/${routeId}/cross-design/preview`, + { + method: "POST", + body: JSON.stringify({ ...edits, standard_cross_section: standardCrossSection ?? null }), + }, + ); +} diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py index cf1d0419..bc3ef125 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py @@ -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, diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py index d97ad9c9..d0b72c85 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py @@ -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}