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>
130 lines
5.5 KiB
TypeScript
130 lines
5.5 KiB
TypeScript
/* =============================================================================
|
||
* B05_Profile_UI_Profile_MinCover.ts
|
||
* 횡단배수 시설이 요구하는 **측점별 최소 계획고**를 산출한다.
|
||
*
|
||
* 왜(2026-08-23 사용자 지시): 배수관·BOX암거는 관(또는 구체) 위에 최소 토피가
|
||
* 있어야 성립한다. 계획선이 그 아래로 내려오면 3D 형상도, 실제 시공도 성립하지
|
||
* 않는다. 그래서 시설 제원으로 최소 계획고를 되짚어 종단에 표시하고, 계획선이
|
||
* 그 아래로 내려오면 경고한다.
|
||
*
|
||
* 산식(사용자 확정 예시 그대로):
|
||
* 배수관 Ø1000 → 지반고 + 1.0(관경) + 0.5(토피) = +1.5
|
||
* BOX암거 2.0×2.0 → 지반고 + 2.0(구체 높이) + 0.5(토피) = +2.5
|
||
* 세월교 Ø1000 → 지반고 + 1.0(관경) + 0.5(토피) + 0.5(물넘이 몫) = +2.0
|
||
* 물넘이포장 → 도로에 그대로 앉히는 시설이라 요구 여유 없음
|
||
* 토피 0.5m는 B06 배수관 엔진의 `MIN_PIPE_COVER_M`과 같은 값이다(교차 확인).
|
||
*
|
||
* **정본 산식은 백엔드** `common_util_drainage_pipes.facility_clearance_m`이다 —
|
||
* 계획선 자동 생성이 그 값으로 변화점을 들어 올린다. 여기 있는 것은 편집 중 즉시
|
||
* 경고하기 위한 같은 산식의 화면 사본이며, 상수 일치는 테스트로 잠가 둔다.
|
||
* ========================================================================== */
|
||
|
||
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||
|
||
/** 최소고 판정에 필요한 것만 받는다 — 관 목록의 출처(정본/화면)에 매이지 않는다. */
|
||
export interface MinCoverPipe {
|
||
chainage_m: number;
|
||
facility?: PipeFacility;
|
||
options?: Record<string, string | number>;
|
||
}
|
||
|
||
/** 최소 토피(m) — B06 `MIN_PIPE_COVER_M`과 같은 값. */
|
||
export const MIN_COVER_M = 0.5;
|
||
|
||
/** 시설 제원이 비었을 때 쓰는 기본값 — 화면 기본 선택과 맞춘다. */
|
||
const DEFAULT_PIPE_DIAMETER_MM = 1000;
|
||
const DEFAULT_BOX_HEIGHT_M = 2;
|
||
/** 세월교 추가 여유(m) — 배관 다발 위에 물넘이가 얹힌다(2026-08-23 사용자 확정). */
|
||
const FORD_BRIDGE_EXTRA_M = 0.5;
|
||
|
||
export interface MinCoverPoint {
|
||
chainage_m: number;
|
||
/** 시설이 요구하는 지반고 대비 최소 여유(m) — 관경/구체높이 + 토피. */
|
||
clearance_m: number;
|
||
/** 표시용 시설 이름(경고 문구에 쓴다). */
|
||
label: string;
|
||
}
|
||
|
||
function numberOf(value: unknown, fallback: number): number {
|
||
const parsed = typeof value === "number" ? value : Number(value);
|
||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||
}
|
||
|
||
/**
|
||
* 관 지점 목록에서 최소 여유가 필요한 측점을 뽑는다.
|
||
* 물넘이포장만 제외 — 도로 위에 그대로 앉는 시설이라 요구 여유가 없다.
|
||
*/
|
||
export function minCoverPoints(pipes: MinCoverPipe[]): MinCoverPoint[] {
|
||
const result: MinCoverPoint[] = [];
|
||
for (const pipe of pipes) {
|
||
const facility = pipe.facility ?? "pipe";
|
||
const options = pipe.options ?? {};
|
||
if (facility === "box_culvert") {
|
||
const heightM = numberOf(options.body_height_m, DEFAULT_BOX_HEIGHT_M);
|
||
result.push({
|
||
chainage_m: pipe.chainage_m,
|
||
clearance_m: heightM + MIN_COVER_M,
|
||
label: `BOX암거 H${heightM.toFixed(1)}`,
|
||
});
|
||
} else if (facility === "ford_pavement") {
|
||
// 물넘이포장은 도로 위에 그대로 만든다 — 들어 올릴 이유가 없다.
|
||
continue;
|
||
} else {
|
||
// 세월교는 배관 다발이라 배수관과 같은 산식 + 물넘이 몫 0.5m.
|
||
const diameterM = numberOf(options.pipe_diameter_mm, DEFAULT_PIPE_DIAMETER_MM) / 1000;
|
||
const extra = facility === "ford_bridge" ? FORD_BRIDGE_EXTRA_M : 0;
|
||
result.push({
|
||
chainage_m: pipe.chainage_m,
|
||
clearance_m: diameterM + MIN_COVER_M + extra,
|
||
label: `${facility === "ford_bridge" ? "세월교" : "배수관"} Ø${Math.round(diameterM * 1000)}`,
|
||
});
|
||
}
|
||
}
|
||
return result.sort((a, b) => a.chainage_m - b.chainage_m);
|
||
}
|
||
|
||
export interface MinCoverViolation extends MinCoverPoint {
|
||
/** 요구 최소 계획고(m). */
|
||
required_m: number;
|
||
/** 현재 계획고(m). */
|
||
planned_m: number;
|
||
/** 모자란 높이(m, 양수). */
|
||
shortfall_m: number;
|
||
}
|
||
|
||
/**
|
||
* 계획선이 최소 계획고에 못 미치는 측점을 찾는다.
|
||
* groundAt/planAt은 chainage로 지반고·계획고를 돌려주는 보간기다.
|
||
*/
|
||
export function findMinCoverViolations(
|
||
points: MinCoverPoint[],
|
||
groundAt: (chainageM: number) => number | null,
|
||
planAt: (chainageM: number) => number | null,
|
||
toleranceM = 0.001,
|
||
): MinCoverViolation[] {
|
||
const violations: MinCoverViolation[] = [];
|
||
for (const point of points) {
|
||
const ground = groundAt(point.chainage_m);
|
||
const planned = planAt(point.chainage_m);
|
||
if (ground === null || planned === null) continue;
|
||
const required = ground + point.clearance_m;
|
||
if (planned < required - toleranceM) {
|
||
violations.push({
|
||
...point,
|
||
required_m: required,
|
||
planned_m: planned,
|
||
shortfall_m: required - planned,
|
||
});
|
||
}
|
||
}
|
||
return violations;
|
||
}
|
||
|
||
/** 경고 문구 — 가장 크게 모자란 곳부터 알려 준다. */
|
||
export function minCoverWarningText(violations: MinCoverViolation[]): string | null {
|
||
if (!violations.length) return null;
|
||
const worst = [...violations].sort((a, b) => b.shortfall_m - a.shortfall_m)[0];
|
||
const more = violations.length > 1 ? ` 외 ${violations.length - 1}곳` : "";
|
||
return `${worst.label} 최소고 ${worst.shortfall_m.toFixed(2)}m 부족${more}`;
|
||
}
|