- B05_Profile_UI_Drainage_Facility_Ford.ts 통째 · 글 검사 시험 목록 한 줄 - POST /route/replan/reset(reset_route_plan) + TS resetRoutePlan · 머리 설명 줄 - TS solveRoute + 딸린 RouteSolveRequest · RouteSolveResponse 타입(엔드포인트 /route/solve 는 남김) - defaultOptions · cutInnerTo · inletKindOf(+ 딸려 죽은 INLET_KINDS) · createBasinSplitFields · COLLAPSED_KEY · pointInRing · minCoverWarningText · designHalfWidths · segmentsIntersect · parseStationText — 파일 안 부름도 0 = 몸통째 - 위키 B05_Profile_Api_Fetch 의 solveRoute 줄 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
181 lines
7.7 KiB
TypeScript
181 lines
7.7 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 { showToast } from "@ui/ui_template_elements";
|
||
import { controlElevationAt } from "./logic/B05_Profile_UI_Profile_Alignment";
|
||
import type { AlignmentBase, ProfileAlignment } from "./logic/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<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;
|
||
}
|
||
|
||
/** 종단 격자에서 그 자리 원지반고 — 가드 판정용 선형 보간. */
|
||
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;
|
||
}
|