fix(B05): 종단 계획고 변경을 횡단 설계에 자동 반영 - B05 진입 시에도 재계산

2026-08-23 사용자 보고: 종단 그래프의 계획고 변화가 B06 횡단도에 반영 안 됨.

원인: 횡단 설계는 계산 당시 계획고(design.design_elevation_m) 기준으로 설계선
좌표를 굳혀 둔다. 계획선이 뒤에 바뀌면(사용자 편집·확정, 배수 최소고 같은
백엔드 규칙 도입) 설계선은 옛 자리에 남는데, 화면의 계획고 십자선과 3D
예상형상은 최신 계획선을 쓴다 - 두 기준이 어긋난다. 실측으로 한 노선에서
최대 2.21m 어긋남 확인. 지금까지 이 보정은 B06 진입 때만 돌아서, B05의 횡단
기준 유토곡선과 3D는 옛 설계선을 계속 썼다.

- staleDesignChainages() 신설(B06_Section_UI_Section_Common) - 계획선 보간값과
  계산 기준의 차이로 재계산 대상을 판정하는 B05·B06 공용 규칙.
- B05 Profile_Panel: 진입 렌더에서 어긋남이 있으면 프리뷰 1회로 맞춘다.
- B06 Page: 같은 공용 규칙을 쓰도록 통일(옛 암 2단계 필드 누락 조건은 유지).

검증: tsc 통과 · pytest 191 passed(신규 stale 판정 6건) · 공용 브라우저 실측 -
계획고·지반고 배치 대조(전 측점 일치, 렌더 역산 RMS 0.004~0.03m), 측점 중심
좌표가 노선 폴리라인과 거리 0, 인위 stale 1건을 B05 진입으로 자동 복구(stale
1 to 0, 계획고 540.895 복원).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 17:40:48 +09:00
co-authored by Claude Fable 5
parent d8080109c1
commit 76df1e9335
3 changed files with 39 additions and 4 deletions
@@ -29,6 +29,7 @@ import { createProgressCircle } from "@ui/ui_template_progress";
import { showToast } from "@ui/ui_template_elements";
import { saveProfileAlignment } from "./B05_Profile_Api_Fetch";
import { previewCrossDesigns } from "../B06_Section/B06_Section_Api_Fetch";
import { staleDesignChainages } from "../B06_Section/B06_Section_UI_Section_Common";
import {
findMinCoverViolations,
minCoverPoints,
@@ -598,6 +599,10 @@ export function createRouteProfilePanel(
);
draw();
requestAnimationFrame(draw);
// 저장된 횡단 설계가 옛 계획고로 굳어 있으면 진입 즉시 한 번 맞춘다 —
// 지금까지는 B06에 들어가야만 고쳐져, B05의 횡단 기준 유토곡선과 3D
// 예상형상이 옛 설계선을 그대로 썼다(2026-08-23 사용자 보고 · 실측 확인).
if (staleDesignChainages(nextDetail).length) scheduleCrossPreview();
},
/** 라이브 계획선 샘플(편집 반영분) — 3D 측점 바·코리도가 이걸 본다(2026-08-23).
* 편집 전·base 없음이면 null — 호출부는 정본 design_profiles로 폴백한다. */
+5 -4
View File
@@ -41,7 +41,7 @@ import {
import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul";
import { computeHaulPlan } from "@util/common_util_mass_haul_balance";
import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view";
import { designElevationAt } from "./B06_Section_UI_Section_Common";
import { staleDesignChainages } from "./B06_Section_UI_Section_Common";
import { createStandardPanel, type StandardPanelController } from "./B06_Section_UI_Standard_Panel";
import "./B06_Section_UI_Style.css";
import "./B06_Section_UI_Style_Cross.css";
@@ -203,13 +203,14 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
*/
async function reconcileStaleDesigns(): Promise<void> {
if (!sectionDetail || !projectId || currentRouteId === null) return;
const profiles = sectionDetail.longitudinal.design_profiles;
// 계획고 어긋남은 B05와 **같은 규칙**으로 판정한다(공용 staleDesignChainages).
// 옛 암 2단계 필드 누락은 B06 전용 조건이라 여기서 더한다.
const staleByPlan = new Set(staleDesignChainages(sectionDetail));
const stale = sectionDetail.cross_sections.filter((section) => {
const design = section.design;
if (!design) return false;
if (design.geometry_preset === "rock" && design.two_stage_slope === undefined) return true;
const planZ = designElevationAt(profiles, section.chainage_m);
return planZ !== undefined && Math.abs(planZ - design.design_elevation_m) > 1e-3;
return staleByPlan.has(section.chainage_m);
});
if (!stale.length) return;
showLoadingOverlay();
@@ -68,6 +68,35 @@ export function validElevation(
* 측점 chainage 위치의 계획고를 계획선 샘플에서 선형보간한다.
* 범위를 벗어나면 양 끝값으로 클램프하며, 계획선이 없으면 undefined를 반환해 지반고 폴백을 유도한다.
*/
/**
* 저장된 횡단 설계가 **현재 계획선과 어긋난 측점**을 찾는다(B05·B06 공용 규칙).
*
* 횡단 설계는 계산 당시 계획고(`design.design_elevation_m`)를 기준으로 설계선 좌표를
* 굳혀 둔다. 계획선이 그 뒤에 바뀌면(사용자 편집·확정, 배수 최소고 같은 백엔드 규칙
* 도입) 설계선은 옛 계획고 자리에 남는데 화면의 계획고 십자선·3D는 최신 계획선을 쓴다
* — 두 기준이 어긋나 횡단도·3D가 종단을 안 따라오는 것처럼 보인다(2026-08-23 실측:
* 한 노선에서 최대 2.21m 어긋남). 재계산 대상을 한 규칙으로 판정해 두 화면이 같은
* 시점에 같은 조치를 하게 한다.
*/
export function staleDesignChainages(
detail: {
longitudinal: { design_profiles?: DesignProfile[] };
cross_sections: Array<{ chainage_m: number; design?: { design_elevation_m: number } | null }>;
},
toleranceM = 1e-3,
): number[] {
const profiles = detail.longitudinal.design_profiles;
if (!profiles?.length) return [];
return detail.cross_sections
.filter((section) => {
const design = section.design;
if (!design) return false;
const planned = designElevationAt(profiles, section.chainage_m);
return planned !== undefined && Math.abs(planned - design.design_elevation_m) > toleranceM;
})
.map((section) => section.chainage_m);
}
export function designElevationAt(
designProfiles: DesignProfile[] | undefined,
chainageM: number,