diff --git a/B05_Profile/B05_Profile_UI_Profile_MinCover.ts b/B05_Profile/B05_Profile_UI_Profile_MinCover.ts index c76ed544..e52f83b3 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MinCover.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MinCover.ts @@ -19,6 +19,10 @@ * 경고하기 위한 같은 산식의 화면 사본이며, 상수 일치는 테스트로 잠가 둔다. * ========================================================================== */ +import { showToast } from "@ui/ui_template_elements"; +import { controlElevationAt } from "./B05_Profile_UI_Profile_Alignment"; +import type { AlignmentBase, ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment"; + import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; /** 최소고 판정에 필요한 것만 받는다 — 관 목록의 출처(정본/화면)에 매이지 않는다. */ @@ -127,3 +131,58 @@ export function minCoverWarningText(violations: MinCoverViolation[]): string | n const more = violations.length > 1 ? ` 외 ${violations.length - 1}곳` : ""; return `${worst.label} 최소고 ${worst.shortfall_m.toFixed(2)}m 부족${more}`; } + +/** 종단 격자에서 그 자리 원지반고 — 가드 판정용 선형 보간. */ +function groundAt(base: AlignmentBase, chainageM: number): number | null { + const { chainage: xs, ground: ys } = base; + if (!xs.length) return null; + if (chainageM <= xs[0]) return ys[0]; + if (chainageM >= xs[xs.length - 1]) return ys[ys.length - 1]; + for (let i = 1; i < xs.length; i += 1) { + if (chainageM > xs[i]) continue; + const span = xs[i] - xs[i - 1]; + if (span <= 0) return ys[i]; + return ys[i - 1] + (ys[i] - ys[i - 1]) * ((chainageM - xs[i - 1]) / span); + } + return ys[ys.length - 1]; +} + +/** + * 편집 후보가 최소 계획고를 깨면 막는다(2026-08-23 개편). + * + * 위반이 **새로 생기거나 커질 때만** 막는다 — 이미 위반이면 악화만 막아 복구(올림) + * 편집은 언제나 통과한다. 판정점은 제어점 z(라운드 중심)다: 곡선 샘플로 재면 이웃 + * 틸팅이 라운드 형상만 바꿔도 잠긴다(2026-08-23 사용자: "옆 지점 틸팅에 락 — 말이 안 됨"). + * + * 호출 자리는 `_Profile_Panel.applyEdits` **한 곳**이다. 측점 끌기에만 걸어 두었더니 + * [직선화]·[쉬프트]·틸팅·방향키가 그냥 지나갔다(2026-09-02 laptop-main 실측). + * 최소고 강제가 꺼져 있으면(기본 해제, 2026-09-01 사용자 지시) 아무것도 막지 않는다. + */ +export function blocksMinCover( + base: AlignmentBase | null, + alignment: ProfileAlignment | null, + candidate: ProfileAlignment, + enforced: boolean, + targets: MinCoverPoint[], +): boolean { + if (!base || !alignment || !enforced || !targets.length) return false; + const ground = (chainageM: number) => groundAt(base, chainageM); + const planned = findMinCoverViolations(targets, ground, (chainageM) => + controlElevationAt(candidate, chainageM), + ); + if (!planned.length) return false; + const current = new Map( + findMinCoverViolations(targets, ground, (chainageM) => + controlElevationAt(alignment, chainageM), + ).map((violation) => [violation.chainage_m, violation.shortfall_m]), + ); + const worsened = planned.find( + (violation) => violation.shortfall_m > (current.get(violation.chainage_m) ?? 0) + 1e-6, + ); + if (!worsened) return false; + showToast( + `${worsened.label} — 최소 계획고(지반 +${worsened.clearance_m.toFixed(1)}m) 아래로 내려갈 수 없습니다.`, + "warning", + ); + return true; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 01e5ea34..5d127f36 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -26,6 +26,7 @@ import { showToast } from "@ui/ui_template_elements"; import { saveProfileAlignment } from "./B05_Profile_Api_Fetch"; import { staleDesignChainages } from "../B06_Section/B06_Section_UI_Section_Common"; import { + blocksMinCover, findMinCoverViolations, minCoverPoints, type MinCoverPoint, @@ -380,6 +381,7 @@ export function createRouteProfilePanel( stationIdOf: (station) => irregularStationId(station.id), moveStation: (station, toChainageM) => callbacks?.onStructureMove?.(station.chainage_m, toChainageM, station), + drainage: drainagePanel, restore: () => { // 세션이 정본이므로 편집 초안을 다시 읽어 그린다. store = createProfileEditStore(routeId, savedEdits, () => rebuild()); @@ -402,6 +404,8 @@ export function createRouteProfilePanel( ); return; } + // 최소고 가드 — 편집 경로가 전부 여기로 모인다(2026-09-02 직선화·쉬프트·틸팅·방향키 누락 수정). + if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return; store.replace(next); history.record(); } @@ -475,7 +479,6 @@ export function createRouteProfilePanel( stationInterval: () => stationInterval, irregularStations: () => irregularStations, minCoverTargets: () => minCoverTargets, - enforceMinCover: () => enforceMinCover, structures: () => structures, structureTypes: () => structureTypes, selectedStationId: () => selectedStationId, diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index b765c5ba..a22a7aaa 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -28,6 +28,9 @@ import type { IrregularStation } from "./B05_Profile_UI_IrregularStations"; /** 방향키 한 번에 움직이는 양(m) — 계획고·누가거리 모두 같다(사용자 확정). */ const KEY_STEP_M = 0.1; +/** 구조물(관) 위치를 이력에 태우는 세션 키 — `b05:` 접두라 스냅샷 대상에 든다. */ +const PIPE_KEY = "b05:pipes"; + export interface PanelToolsContext { /** 패널 루트 — 키보드 조작이 이 안에서 일어났을 때만 반응한다. */ root: HTMLElement; @@ -43,6 +46,11 @@ export interface PanelToolsContext { stationIdOf: (station: IrregularStation) => string; /** 구조물·비정규 측점을 다른 누가거리로 옮긴다(그래프 끌기와 같은 경로). */ moveStation: (station: IrregularStation, toChainageM: number) => void; + /** 관 목록 정본 — 되돌리기가 구조물 위치까지 되돌리려면 이력이 이 값을 봐야 한다. */ + drainage: { + pipeChainages: () => number[]; + setPipeChainages: (chainages: number[]) => void; + }; /** 세션 복원 후 화면을 다시 세운다(편집 초안 재적재 포함). */ restore: () => void; /** 도구 상태가 바뀌어 요약줄을 다시 그려야 할 때. */ @@ -57,7 +65,37 @@ export interface PanelTools { } export function createPanelTools(ctx: PanelToolsContext): PanelTools { - const history = createProfileHistory(ctx.restore); + /** 이력이 처음 본 관 목록 — 최초 스냅샷에는 아직 키가 없어 여기로 되돌린다. */ + let initialPipes: number[] | null = null; + + /** 관 위치를 스냅샷이 볼 수 있는 세션 키로 옮겨 적는다(기록 직전에 부른다). */ + function syncPipes(): void { + const chainages = ctx.drainage.pipeChainages(); + if (initialPipes === null) initialPipes = chainages; + sessionStorage.setItem(PIPE_KEY, JSON.stringify(chainages)); + } + + /** 스냅샷을 되돌린 뒤 관 위치도 그 시점 값으로 맞춘다. 같은 목록이면 조용히 끝난다. */ + function restorePipes(): void { + let chainages = initialPipes; + const raw = sessionStorage.getItem(PIPE_KEY); + if (raw) { + try { + chainages = JSON.parse(raw) as number[]; + } catch { + // 손상된 값은 최초 목록으로 되돌린다. + } + } + if (chainages) ctx.drainage.setPipeChainages(chainages); + } + + // 계획선 편집만 되돌리면 구조물 위치가 어긋난다(사용자 확정 ⑥ — 범위는 B05 조작 전부). + const inner = createProfileHistory(() => { + ctx.restore(); + restorePipes(); + }); + /** 기록 직전에 관 목록을 세션으로 흘려 스냅샷에 같이 담기게 한다. */ + const history: ProfileHistory = { ...inner, record: () => (syncPipes(), inner.record()) }; const tools = createProfileTools({ onStraighten: (fromM, toM) => { @@ -76,10 +114,10 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { if (!base) return; ctx.applyEdits(tiltStraightRun(base, ctx.edits(), run, delta)); }, - onUndo: () => history.undo(), - onRedo: () => history.redo(), - canUndo: () => history.canUndo(), - canRedo: () => history.canRedo(), + onUndo: () => inner.undo(), + onRedo: () => inner.redo(), + canUndo: () => inner.canUndo(), + canRedo: () => inner.canRedo(), onChanged: ctx.refresh, }); diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 2921a9a1..5f53b99d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -8,7 +8,6 @@ * 거친다. 그래프·테이블·유토곡선이 같은 X 매핑을 쓰는 규칙은 그대로다. * ========================================================================== */ -import { showToast } from "@ui/ui_template_elements"; import { createLongitudinalProfile, longitudinalMinimumWidth, @@ -18,15 +17,13 @@ import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch import { normalizedLongitudinal, toDesignProfile } from "./B05_Profile_UI_Profile_Data"; import { adjustStation, - buildAlignment, - controlElevationAt, setCurveRadius, type AlignmentBase, type AlignmentEdits, type ProfileAlignment, } from "./B05_Profile_UI_Profile_Alignment"; import { createEditOverlay } from "./B05_Profile_UI_Profile_Edit"; -import { findMinCoverViolations, type MinCoverPoint } from "./B05_Profile_UI_Profile_MinCover"; +import type { MinCoverPoint } from "./B05_Profile_UI_Profile_MinCover"; import { buildStickyYAxis, type RouteMassHaulDrawParams } from "./B05_Profile_UI_Profile_MassHaul"; import { createProfileTable } from "./B05_Profile_UI_Profile_Table"; import { @@ -64,10 +61,9 @@ export interface ProfileRenderContext { base: () => AlignmentBase | null; stationInterval: () => number | undefined; irregularStations: () => IrregularStation[]; - /** 횡단배수 최소 계획고 대상(시설·제원 반영) — 편집 차단 가드가 쓴다. */ + /** 횡단배수 최소 계획고 대상(시설·제원 반영) — 요약줄 경고 표시에 쓴다. + * 편집 차단 가드는 `_Profile_Panel.applyEdits` 한 곳으로 옮겼다(2026-09-02). */ minCoverTargets: () => MinCoverPoint[]; - /** 최소고를 편집에서 강제할지 — 꺼져 있으면 차단하지 않는다(2026-09-01, 기본 해제). */ - enforceMinCover: () => boolean; structures: () => StructureInstance[]; structureTypes: () => StructureType[]; selectedStationId: () => string | null; @@ -291,55 +287,6 @@ export function renderProfile(ctx: ProfileRenderContext): void { // 아래로 밀려 스크롤 시 축이 화면에 안 보인다(2026-08-04 확인, B06과 같은 규칙). if (yAxis) chartWrap.prepend(buildStickyYAxis(yAxis, chartHeight)); if (alignment) { - // 횡단배수 최소 계획고 가드(2026-08-23 개편): 편집 **후보**로 정렬을 미리 계산해 - // 시설별 최소고(배수관 관경+토피 · BOX암거 구체높이+토피 · 세월교 +물넘이 몫, - // 산식은 minCoverPoints 동일 원천) 위반이 **새로 생기거나 커지면** 차단한다. - // 측점 ▼뿐 아니라 구간 ⇧⇩, 이웃 틸트가 종단곡선(중앙종거)을 거쳐 배관 계획고를 - // 내리는 경로까지 같은 가드로 잡는다 — 기존 관경 고정 산식은 구간 쉬프트를 아예 - // 안 막았고 BOX암거·세월교를 과소 차단했다(2026-08-23 사용자 보고). - // 이미 위반이면 악화만 막는다 — 복구 편집(올림)은 항상 허용돼야 한다. - const groundAt = (chainageM: number): number | null => { - if (!base || !base.chainage.length) return null; - const { chainage: xs, ground: ys } = base; - if (chainageM <= xs[0]) return ys[0]; - if (chainageM >= xs[xs.length - 1]) return ys[ys.length - 1]; - for (let i = 1; i < xs.length; i += 1) { - if (chainageM > xs[i]) continue; - const span = xs[i] - xs[i - 1]; - if (span <= 0) return ys[i]; - return ys[i - 1] + (ys[i] - ys[i - 1]) * ((chainageM - xs[i - 1]) / span); - } - return ys[ys.length - 1]; - }; - const blocksMinCover = (next: AlignmentEdits): boolean => { - if (!base) return false; - // 최소고 강제가 꺼져 있으면 막지 않는다(2026-09-01 사용자 지시 — 기본 해제). - // 부족분은 상단 표시줄 경고(minCoverWarningText)로 계속 알린다. - if (!ctx.enforceMinCover()) return false; - const targets = ctx.minCoverTargets(); - if (!targets.length) return false; - // 판정점 = 제어점 z(라운드 중심). 곡선 샘플로 재면 이웃 틸팅이 라운드 형상만 - // 바꿔도 잠긴다(2026-08-23 사용자: "옆 지점 틸팅에 락 — 말이 안 됨"). - const candidate = buildAlignment(base, next); - const planned = findMinCoverViolations(targets, groundAt, (chainageM) => - controlElevationAt(candidate, chainageM), - ); - if (!planned.length) return false; - const current = new Map( - findMinCoverViolations(targets, groundAt, (chainageM) => - controlElevationAt(alignment, chainageM), - ).map((violation) => [violation.chainage_m, violation.shortfall_m]), - ); - const worsened = planned.find( - (violation) => violation.shortfall_m > (current.get(violation.chainage_m) ?? 0) + 1e-6, - ); - if (!worsened) return false; - showToast( - `${worsened.label} — 최소 계획고(지반 +${worsened.clearance_m.toFixed(1)}m) 아래로 내려갈 수 없습니다.`, - "warning", - ); - return true; - }; chartWrap.append( createEditOverlay({ alignment, @@ -351,11 +298,11 @@ export function renderProfile(ctx: ProfileRenderContext): void { (entry) => entry.chainage_m >= 0 && entry.chainage_m <= maxChainageOf(longitudinal) + 1e-6, ), + // 최소고 가드는 `_Profile_Panel.applyEdits` 한 곳에 있다 — 여기 따로 걸면 + // 다른 편집 경로(직선화·쉬프트·틸팅·방향키)와 규칙이 갈린다(2026-09-02). onStation: (chainage, delta) => { if (!base) return; - const next = adjustStation(base, store.edits(), chainage, delta); - if (blocksMinCover(next)) return; - ctx.applyEdits(next); + ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta)); }, }), );