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
+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));
},
}),
);