fix(B05): 종단 편집 최소고 가드 개편 - 구간 쉬프트 차단 + 시설별 산식

2026-08-23 사용자 보고: 횡단배수 최소고를 어기고 구간 쉬프트가 되는 경우
있음(가드가 측점 ▼에만, 관경 고정 산식).

- 가드 개편(blocksMinCover): 편집 후보로 정렬을 계산해 시설별 최소고
  (minCoverPoints 원천 - 배수관 관경+토피, BOX암거 구체높이+토피, 세월교
  +물넘이, 물넘이포장 제외) 위반이 새로 생기거나 커지면 차단. 측점 ▲▼와
  구간 ⇧⇩ 모두 적용, 이웃 틸트가 종단곡선을 거쳐 배관 계획고를 내리는
  경로까지 커버. 이미 위반이면 악화만 차단(복구 편집 허용). 기존
  blocksPipeLowering(측점 ▼ 전용·관경 1000 고정)은 제거.
- shiftSegment 시딩 수정: 초기 오프셋을 곡선 샘플 계획고(PVI z - 중앙종거)
  대신 변화점 실제 z로 계산 - 첫 이동에서 변화점이 내려앉아 상승 후 하강
  복귀가 비대칭이던 화면 실측 버그 해소.
- MinCover 낡은 주석 정정(구현은 세월교 포함, 물넘이포장만 제외).

검증: tsc 통과 · pytest 182 passed(가드·시딩 신규 8건) · 공용 브라우저
실조작 - 배수관 앵커 구간 ⬇ 즉시 차단(+1.5), BOX암거 구간 차단(+2.5),
⬆/⬇ 대칭 복귀, 검증 상태 전부 원복.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 16:27:41 +09:00
co-authored by Claude Fable 5
parent 8393d22c23
commit eb716ffbb1
4 changed files with 51 additions and 26 deletions
@@ -502,10 +502,18 @@ export function shiftSegment(
const baseZ = base.basePvi.map((node) => node.elevation_m);
const current = buildAlignment(base, edits);
const offsets = { ...edits.station_offsets };
// 구간 끝은 항상 변화점이다 — 초기 오프셋은 **변화점 실제 z** 기준으로 잡는다.
// 곡선 샘플 계획고(= PVI z − 중앙종거)를 쓰면 첫 이동에서 변화점이 중앙종거만큼
// 내려앉아 ⬆ 뒤 ⬇ 복귀가 원위치보다 낮아진다(2026-08-23 화면 실측: 복귀가
// 최소고 가드에 걸림). 변화점이 못 찾아지는 예외에서만 샘플 보간으로 폴백한다.
const nodeElevationAt = (chainage: number): number => {
const node = current.pvi.find((entry) => Math.abs(entry.chainage_m - chainage) < 1e-6);
return node ? node.elevation_m : planElevationAt(current, chainage);
};
[segment.from_m, segment.to_m].forEach((chainage) => {
const key = chainageKey(chainage);
const existing =
offsets[key] ?? planElevationAt(current, chainage) - interpolate(baseS, baseZ, chainage);
offsets[key] ?? nodeElevationAt(chainage) - interpolate(baseS, baseZ, chainage);
offsets[key] = Number((existing + delta).toFixed(6));
});
return { ...edits, station_offsets: offsets };
@@ -52,7 +52,7 @@ function numberOf(value: unknown, fallback: number): number {
/**
* 관 지점 목록에서 최소 여유가 필요한 측점을 뽑는다.
* 배수관·BOX암거만 대상(세월교·물넘이포장 제외).
* 물넘이포장 제외 — 도로 위에 그대로 앉는 시설이라 요구 여유가 없다.
*/
export function minCoverPoints(pipes: MinCoverPipe[]): MinCoverPoint[] {
const result: MinCoverPoint[] = [];
@@ -500,6 +500,7 @@ export function createRouteProfilePanel(
base: () => base,
stationInterval: () => stationInterval,
irregularStations: () => irregularStations,
minCoverTargets: () => minCoverTargets,
structures: () => structures,
structureTypes: () => structureTypes,
selectedStationId: () => selectedStationId,
+40 -24
View File
@@ -14,11 +14,11 @@ import {
longitudinalMinimumWidth,
} from "../B06_Section/B06_Section_UI_Longitudinal";
import { LONG_PAD } from "../B06_Section/B06_Section_UI_Section_Common";
import { MIN_PIPE_COVER_M } from "../B06_Section/B06_Section_UI_Cross_Culvert";
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
import { normalizedLongitudinal, toDesignProfile } from "./B05_Profile_UI_Profile_Data";
import {
adjustStation,
buildAlignment,
planElevationAt,
setCurveRadius,
shiftSegment,
@@ -27,6 +27,7 @@ import {
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 { buildStickyYAxis, type RouteMassHaulDrawParams } from "./B05_Profile_UI_Profile_MassHaul";
import { createProfileTable } from "./B05_Profile_UI_Profile_Table";
import {
@@ -39,11 +40,7 @@ import {
STATION_SPACING_PX,
TABLE_ROW_COUNT,
} from "./B05_Profile_UI_Profile_Layout";
import {
irregularStationId,
isPipeStation,
type IrregularStation,
} from "./B05_Profile_UI_IrregularStations";
import { irregularStationId, type IrregularStation } from "./B05_Profile_UI_IrregularStations";
import { mountStructureMenu } from "./B05_Profile_UI_Profile_Structures";
import { buildStructureLane, STRUCTURE_LANE_HEIGHT_PX } from "./B05_Profile_UI_Structures_Marks";
import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures";
@@ -68,6 +65,8 @@ export interface ProfileRenderContext {
base: () => AlignmentBase | null;
stationInterval: () => number | undefined;
irregularStations: () => IrregularStation[];
/** 횡단배수 최소 계획고 대상(시설·제원 반영) — 편집 차단 가드가 쓴다. */
minCoverTargets: () => MinCoverPoint[];
structures: () => StructureInstance[];
structureTypes: () => StructureType[];
selectedStationId: () => string | null;
@@ -270,9 +269,13 @@ export function renderProfile(ctx: ProfileRenderContext): void {
// 아래로 밀려 스크롤 시 축이 화면에 안 보인다(2026-08-04 확인, B06과 같은 규칙).
if (yAxis) chartWrap.prepend(buildStickyYAxis(yAxis, chartHeight));
if (alignment) {
// 배수관 측점 계획고 하향 차단(2026-08-19): 관은 지반선상 매설이라(교본 8장)
// 계획고가 지반고 + 관경 + 최소 토피 아래로 내려가면 관이 노면을 뚫는다.
// 내림(▼)만 막고 올림(▲)·다른 측점은 그대로 둔다 — 판단은 사용자 몫, 한계만 지킨다.
// 횡단배수 최소 계획고 가드(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;
@@ -286,19 +289,26 @@ export function renderProfile(ctx: ProfileRenderContext): void {
}
return ys[ys.length - 1];
};
const blocksPipeLowering = (chainageM: number, delta: number): boolean => {
if (delta >= 0) return false;
const pipe = irregularStations.find(
(entry) => isPipeStation(entry) && Math.abs(entry.chainage_m - chainageM) < 0.02,
const blocksMinCover = (next: AlignmentEdits): boolean => {
if (!base) return false;
const targets = ctx.minCoverTargets();
if (!targets.length) return false;
const candidate = buildAlignment(base, next);
const planned = findMinCoverViolations(targets, groundAt, (chainageM) =>
planElevationAt(candidate, chainageM),
);
if (!pipe) return false;
const ground = groundAt(chainageM);
if (ground === null) return false;
const diameterM = (pipe.diameterMm ?? 1000) / 1000;
const minPlan = ground + diameterM + MIN_PIPE_COVER_M;
if (planElevationAt(alignment, chainageM) + delta >= minPlan - 1e-6) return false;
if (!planned.length) return false;
const current = new Map(
findMinCoverViolations(targets, groundAt, (chainageM) =>
planElevationAt(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(
`배수관 측점 — 관 매설 최소 높이(지반 + 관경 ${diameterM.toFixed(1)}m + 토피 ${MIN_PIPE_COVER_M.toFixed(1)}m)에 도달해 더 내릴 수 없습니다.`,
`${worsened.label} — 최소 계획고(지반 +${worsened.clearance_m.toFixed(1)}m) 아래로 내려갈 수 없습니다.`,
"warning",
);
return true;
@@ -315,11 +325,17 @@ export function renderProfile(ctx: ProfileRenderContext): void {
entry.chainage_m >= 0 && entry.chainage_m <= maxChainageOf(longitudinal) + 1e-6,
),
onStation: (chainage, delta) => {
if (!base || blocksPipeLowering(chainage, delta)) return;
ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta));
if (!base) return;
const next = adjustStation(base, store.edits(), chainage, delta);
if (blocksMinCover(next)) return;
ctx.applyEdits(next);
},
onSegment: (segment, delta) => {
if (!base) return;
const next = shiftSegment(base, store.edits(), segment, delta);
if (blocksMinCover(next)) return;
ctx.applyEdits(next);
},
onSegment: (segment, delta) =>
base && ctx.applyEdits(shiftSegment(base, store.edits(), segment, delta)),
onResetStation: (chainage) => store.resetStation(chainage),
// 구간 원복 = 양 끝 측점 오프셋 삭제(측점 원복 연산 ×2).
onResetSegment: (segment) => {