diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index 456ab36d..b31ac9ce 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -124,6 +124,12 @@ export interface RouteMassHaulDrawParams { onSelectStation: (stationId: string) => void; /** 측점선이 아닌 빈 곳 클릭 — 선택 해제(2026-08-04 사용자 지시). */ onClearSelection?: () => void; + /** + * 저장된 횡단이 지금 계획선과 어긋나 재계산이 예약된 상태. 그 사이의 면적은 **옛 + * 계획고로 만든 값**이라 그리면 사용자가 옛 그림을 보게 된다(2026-09-03 사용자 확정: + * 「새 값만 보여주기」). Panel이 `staleDesignChainages`로 판정해 넘긴다. + */ + pendingRecalc?: boolean; } export interface RouteMassHaulPanel { @@ -306,6 +312,13 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa note("토량환산계수를 불러오지 못해 유토곡선을 계산할 수 없습니다."); return; } + // 저장된 횡단이 지금 계획선과 어긋나 있으면 그 면적은 **옛 계획고로 만든 값**이다. + // 그걸 잠깐 그렸다가 재계산 결과로 갈아 끼우면 사용자가 옛 그림을 본다(2026-09-03 + // 사용자 확정: 「새 값만 보여주기」). Panel이 이미 재계산을 예약해 두므로 기다린다. + if (params.pendingRecalc) { + note("횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다."); + return; + } // B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다. const series: MassHaulSeries[] = computeMassHaulSeries( params.longitudinal, diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 5f53b99d..f2f34eec 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -12,7 +12,7 @@ import { createLongitudinalProfile, longitudinalMinimumWidth, } from "../B06_Section/B06_Section_UI_Longitudinal"; -import { LONG_PAD } from "../B06_Section/B06_Section_UI_Section_Common"; +import { LONG_PAD, staleDesignChainages } from "../B06_Section/B06_Section_UI_Section_Common"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; import { normalizedLongitudinal, toDesignProfile } from "./B05_Profile_UI_Profile_Data"; import { @@ -326,6 +326,9 @@ export function renderProfile(ctx: ProfileRenderContext): void { // 측점별 횡단 설계(기본 프리뷰 포함)를 입력으로 쓴다 — B06과 같은 재료다. longitudinal: { length_m: longitudinal.length_m, design_profiles: designProfiles }, crossSections: detail.cross_sections, + // 저장된 횡단이 지금 계획선과 어긋나면 그 면적은 옛 계획고로 만든 값이다 — + // Panel이 재계산을 예약해 두므로 유토곡선은 그 결과만 그린다(2026-09-03 사용자 확정). + pendingRecalc: staleDesignChainages(detail).length > 0, axis: { maxChainageM: maxChainageOf(longitudinal), // 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를 diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 23d7e724..e0c0b13c 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -435,6 +435,12 @@ export interface CrossDesign { fill_area_m2: number; /** 성토측 자연 지반 경사(rise/run). 자연방토 판정 입력. 성토측이 없으면 null. */ fill_ground_slope?: number | null; + /** + * 사면이 샘플 범위(±계산 반폭) 끝에서도 원지반과 만나지 않은 측점. 면적이 거기서 + * 잘려 절·성토량이 실제와 다르다 — 계산은 그대로 두고 카드에 경고만 단다 + * (2026-09-03 사용자 확정: 「영원히 못 만나는 지형이 있을 수 있으니 경고로 대체」). + */ + slope_unclosed?: boolean; ditch_area_m2: number; design_line: Array<{ offset_m: number; elevation_m: number }>; /** 확정 시 병합되는 암 경계선 오프셋(m). 세션 값이 우선이며 복원 폴백으로 쓴다. */ diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 669e1eb1..b54486ae 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -31,6 +31,10 @@ from config.config_system import ( ) +# 사면이 원지반과 만났다고 볼 높이차(m). 이보다 크면 샘플 끝에서 잘린 것으로 본다. +_SLOPE_CLOSE_TOLERANCE_M = 0.01 + + def _side_role(section_mode: str) -> tuple[str, str]: """단면유형 → (좌측 역할, 우측 역할). 역할은 'cut' 또는 'fill'.""" if section_mode == "left_cut": @@ -533,6 +537,12 @@ def compute_cross_design( # 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음 — 이중계상 방지). cut_area, fill_area = _trapezoid_areas(offsets, diffs) fill_ground_slope = geometry.fill_ground_slope() + # 사면이 샘플 범위 끝에서도 원지반과 만나지 않으면 면적이 거기서 잘린다 — 그만큼 + # 절·성토량이 실제와 다르고 유토곡선도 그 값을 그대로 쌓는다. 영원히 안 만나는 + # 지형이 있을 수 있으므로 계산은 손대지 않고 **경고만** 낸다(2026-09-03 사용자 확정). + slope_unclosed = bool(diffs) and ( + abs(diffs[0]) > _SLOPE_CLOSE_TOLERANCE_M or abs(diffs[-1]) > _SLOPE_CLOSE_TOLERANCE_M + ) # 절토면적 토사/암반 분리 — 지표면~암반 경계선이 토사, 그 아래가 암이다. 경계선 위치가 # 곧 유토곡선 EA/RR/BR 비율을 만들므로, 사용자가 경계선을 올리내리면 이 값이 함께 바뀐다. @@ -629,6 +639,8 @@ def compute_cross_design( "cut_rock_area_m2": round(cut_rock_area, 4), "cut_rock_kind": cut_rock_kind, "fill_area_m2": round(fill_area, 4), + # 사면이 샘플 범위 끝까지 원지반을 못 만나 면적이 잘린 측점 — 경고 표기용. + "slope_unclosed": slope_unclosed, # 성토측 자연 지반 경사(rise/run) — 자연방토 판정 입력. 성토측이 없으면 None. "fill_ground_slope": ( round(fill_ground_slope, 4) if fill_ground_slope is not None else None diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index d563bb15..9b67101e 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -29,6 +29,7 @@ PREVIEW_DESIGN_FIELDS = ( "fill_area_m2", "fill_ground_slope", "design_elevation_m", + "slope_unclosed", ) diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 36b0188a..941785d2 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -309,6 +309,15 @@ export function createCrossSectionCard( const meta = document.createElement("div"); meta.className = "b06-cross-card__meta"; + // 사면이 계산 반폭 끝까지 원지반을 못 만난 측점 — 면적이 거기서 잘려 유토곡선까지 + // 그 값을 쌓는다. 계산은 손대지 않고 사실만 알린다(2026-09-03 사용자 확정). + if (section.design?.slope_unclosed) { + const openSlope = document.createElement("span"); + openSlope.className = "b06-cross-card__warning"; + openSlope.textContent = `⚠ ${L("B06_Cross_SlopeUnclosed")}`; + openSlope.title = L("B06_Cross_SlopeUnclosed_Tip"); + meta.append(openSlope); + } if (section.structure) { const structure = document.createElement("span"); structure.className = "b06-cross-card__structure"; diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 515cee82..e3a56afd 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -84,6 +84,7 @@ import { L, longitudinalMaxChainage, LONG_PAD, + staleDesignChainages, type YScaleOptions, } from "./B06_Section_UI_Section_Common"; @@ -516,14 +517,19 @@ export function createSectionView( ), ]; - const series: MassHaulSeries[] = currentConversion - ? computeMassHaulSeries( - detail.longitudinal, - detail.cross_sections, - currentConversion, - currentNaturalSpoilSlope, - ) - : []; + // 저장된 횡단이 지금 계획선과 어긋나면 그 면적은 **옛 계획고로 만든 값**이다. 진입 + // 직후 `reconcileStaleDesigns()`가 다시 계산해 갈아 끼우므로, 그 사이 옛 값으로 곡선을 + // 그리면 사용자가 옛 그림을 본다(2026-09-03 사용자 확정: 「새 값만 보여주기」). + const pendingRecalc = staleDesignChainages(detail).length > 0; + const series: MassHaulSeries[] = + currentConversion && !pendingRecalc + ? computeMassHaulSeries( + detail.longitudinal, + detail.cross_sections, + currentConversion, + currentNaturalSpoilSlope, + ) + : []; // 토량 분배는 **면을 깐 곡선 하나**(= 켜 둔 첫 곡선)에만 얹는다 — 곡선마다 평형선을 // 그리면 계단이 서로 엇갈려 어느 쪽 배분인지 읽히지 않는다. const bandedSeries = series.find((entry) => visibleSeries.has(entry.key)); @@ -588,7 +594,9 @@ export function createSectionView( panelCount.textContent = ""; panelBody.append(createMassHaulSummary(summarySeries, haulPlan)); } else { - panelCount.textContent = L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty"); + panelCount.textContent = pendingRecalc + ? L("B06_MassHaul_Recalculating") + : L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty"); } chartWrap.scrollLeft = keepScrollLeft; // 상단 패널이 sticky라 선택 카드가 그 아래로 숨는다 — 패널 높이만큼 스크롤 여백을 잡아 준다. diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 0a3d4b5c..ee013e19 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -232,6 +232,12 @@ flex: 0 0 auto; } +/* 사면 미교차 경고 — 면적이 계산 반폭에서 잘린 측점(2026-09-03). */ +.b06-cross-card__warning { + color: var(--color-warning); + white-space: nowrap; +} + /* 지반유형 세그먼트(D-6): 제목행 1행 내 인라인 배치. 좌측 구분선(3번). */ .b06-design__seg--header { flex: 0 0 auto; diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 4d9e4080..c87995bd 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -341,6 +341,13 @@ export const ui_locales_b2 = { "측점에 지반유형을 지정하면 유토곡선이 표시됩니다.", "The mass haul diagram appears once ground types are assigned to stations.", ], + /* 사면이 계산 반폭(지반 샘플 범위) 끝에서도 원지반과 안 만난 측점 경고. + 계산은 그대로 두고 사실만 알린다(2026-09-03 사용자 확정). */ + B06_Cross_SlopeUnclosed: ["사면 미교차", "Slope not closed"], + B06_Cross_SlopeUnclosed_Tip: [ + "사면이 계산 반폭 끝까지 원지반과 만나지 않아 절·성토 면적이 그 자리에서 잘렸습니다. 유토곡선·수량도 잘린 값을 씁니다.", + "The slope never meets existing ground within the computed half-width, so the cut/fill area is truncated there. The mass haul curve and quantities use the truncated value.", + ], B06_MassHaul_CutNatural: ["절토(자연)", "Cut (natural)"], B06_MassHaul_CutCompacted: ["절토(다짐환산)", "Cut (compacted)"], B06_MassHaul_Fill: ["성토", "Fill"], @@ -356,6 +363,12 @@ export const ui_locales_b2 = { "노선 끝의 누가토량 — 0에 가까울수록 절·성토 균형. 곡선이 0선을 넘지 않으면 평형선·운반 블록이 생기지 않습니다.", "Cumulative volume at the end of the route — the closer to zero, the better cut/fill balance. No balance lines or haul blocks appear while the curve never crosses zero.", ], + /* 저장된 횡단이 옛 계획고로 굳어 있어 재계산을 기다리는 동안 — 옛 값으로 그린 곡선을 + 보여 주지 않는다(2026-09-03 사용자 확정: 「새 값만 보여주기」). */ + B06_MassHaul_Recalculating: [ + "횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다.", + "Recalculating cross sections against the current profile.", + ], B06_MassHaul_AllHidden: [ "표시할 곡선을 하나 이상 켜 주세요.", "Turn on at least one curve to display.",