2026-09-02 화면 검증에서 나온 미해결 2건 처리. - 최소고 가드: `blocksMinCover` 가 `_Profile_Render` 측점 끌기 한 곳에만 있어 [직선화]·[쉬프트]·틸팅·방향키가 그냥 지나감. 판정 로직을 `_Profile_MinCover.blocksMinCover()` 로 옮기고 모든 편집이 지나는 `_Profile_Panel.applyEdits` 한 곳에서만 호출. 화면 모듈의 사본·죽은 배선 (`enforceMinCover` ctx 필드, 미사용 import)은 제거. - 되돌리기 범위: 이력이 `b05:`·`b05-`·`b06:`·`b06-` 세션 키만 스냅샷하는데 구조물(관) 위치는 세션 밖이라 undo 가 계획선만 되돌림. `_Profile_Panel_Tools` 가 기록 직전에 관 목록을 `b05:pipes` 로 흘리고 복원 뒤 되읽어 반영. 사용자 확정 ⑥ 「범위는 B05 페이지 조작 전부」 충족. 검증(공용 브라우저, 용화 검증 프로젝트 route_id 126): - 최소고 강제 켠 상태로 관 측점 264.060 방향키 ↓ → 편집 미적용 + 경고 "배수관 Ø800 — 최소 계획고(지반 +1.3m) 아래로 내려갈 수 없습니다." - 구조물 측점 264.060 → 263.960 이동 후 ↶ 1회 → 264.060 복귀, `b05:pipes` 목록도 264.06 으로 복귀. - `tsc --noEmit` 0, prettier 통과, pytest 366 passed / 17 skipped / 0 failed. - 파일 줄수: Panel 699 · Panel_Tools 197 · Render 350 · MinCover 188 (700줄 제한 준수). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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 "./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<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;
|
||
}
|