- 옮김: B04 2 · B05 23 · B06 11 · A00_Common/design 13 (9,299 줄) · 이미 logic/ 에 있던 16 파일 그대로 - 안 옮김: 묶음 입구(package.json build:*) · 파이썬 · 순수지만 계산 아닌 8 (보기 결선 · 콜백 · API 타입 · 갈아끼움 알림판) - import 266 줄(142 파일) · 시험 · 주석 · 짝 표시 문자열 경로 고침 · B05 · B06 CLAUDE.md 짝 줄 logic/ - 시험 도우미 새 경로: 가짜 require 열쇠 · B05()/B06() 부름 · 형제 import 자리 · tsc 묶어 돌리는 시험 · 정규식 · rglob - design_view_cross 줄 한도 시험 685 → 688 (긴 import 경로 줄바꿈 3줄) - 확인: typecheck 0 · 서버 묶음 넷(corridor · server-calc · b07-extent · b07-note) 다시 지어 코드 같음(//#region 경로 주석만 다름) · 관련 시험 270 파일 — 남은 실패 19 는 옮기기 전과 같음 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
189 lines
8.1 KiB
TypeScript
189 lines
8.1 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;
|
||
}
|
||
|
||
/** 경고 문구 — 가장 크게 모자란 곳부터 알려 준다. */
|
||
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;
|
||
}
|