/* ============================================================================= * 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 { 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"; /** 최소고 판정에 필요한 것만 받는다 — 관 목록의 출처(정본/화면)에 매이지 않는다. */ export interface MinCoverPipe { chainage_m: number; facility?: PipeFacility; options?: Record; } /** 최소 토피(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}`; } /** 종단 격자에서 그 자리 원지반고 — 가드 판정용 선형 보간. */ 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; }