fix(B05): 최소고 가드를 편집 단일 창구로 이관 + 되돌리기가 구조물 위치까지 복원

2026-09-02 화면 검증에서 나온 미해결 2건 처리.

- 최소고 가드: `blocksMinCover` 가 `_Profile_Render` 측점 끌기 한 곳에만 있어
  [직선화]·[쉬프트]·틸팅·방향키가 그냥 지나감. 판정 로직을
  `_Profile_MinCover.blocksMinCover()` 로 옮기고 모든 편집이 지나는
  `_Profile_Panel.applyEdits` 한 곳에서만 호출. 화면 모듈의 사본·죽은 배선
  (`enforceMinCover` ctx 필드, 미사용 import)은 제거.
- 되돌리기 범위: 이력이 `b05:`·`b05-`·`b06:`·`b06-` 세션 키만 스냅샷하는데
  구조물(관) 위치는 세션 밖이라 undo 가 계획선만 되돌림. `_Profile_Panel_Tools`
  가 기록 직전에 관 목록을 `b05:pipes` 로 흘리고 복원 뒤 되읽어 반영.
  사용자 확정 ⑥ 「범위는 B05 페이지 조작 전부」 충족.

검증(공용 브라우저, 용화 검증 프로젝트 route_id 126):
- 최소고 강제 켠 상태로 관 측점 264.060 방향키 ↓ → 편집 미적용 + 경고
  "배수관 Ø800 — 최소 계획고(지반 +1.3m) 아래로 내려갈 수 없습니다."
- 구조물 측점 264.060 → 263.960 이동 후 ↶ 1회 → 264.060 복귀,
  `b05:pipes` 목록도 264.06 으로 복귀.
- `tsc --noEmit` 0, prettier 통과, pytest 366 passed / 17 skipped / 0 failed.
- 파일 줄수: Panel 699 · Panel_Tools 197 · Render 350 · MinCover 188 (700줄 제한 준수).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-02 20:34:18 +09:00
co-authored by Claude Opus 5
parent 73a77a1376
commit e065e5701e
4 changed files with 112 additions and 65 deletions
@@ -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;
}
+4 -1
View File
@@ -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,
@@ -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,
});
+6 -59
View File
@@ -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));
},
}),
);