Files
Aislo/B05_Profile/B05_Profile_UI_Profile_MinCover.ts
T
eomsangdonandClaude Opus 5 e065e5701e fix(B05): 최소고 가드를 편집 단일 창구로 이관 + 되돌리기가 구조물 위치까지 복원
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>
2026-09-02 20:34:18 +09:00

189 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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;
}