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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user