fix(B06): 성토 물매 1:1.2 하한을 자동 자리에도 강제

기슭막이 기본 자리를 사면 끝으로 바꾼 뒤 물매가 규정보다 급해지는 측점이 생겼다
(13+15.7 실측 1:0.865 — 성토_비탈면.md §1의 1:1.2~2.0 위반). 1:1.2는 지켜야 하는
하한이므로, 사면 끝 자리의 물매가 그보다 급하면 **1:1.2에 닿을 때까지 바깥으로**
민다. 지반이 1:1.2보다 급해 끝내 못 맞추면 사면 끝을 그대로 쓰고 경고로 알린다.

물매 계산의 이음선 위치도 바로잡았다 — 자리 기준이 하단선 중점으로 바뀐 뒤에도
`REVET_TRAP_TOP_M`을 그대로 더해 실제보다 0.3m 바깥으로 보고 있었다.
`fillWallBaseWidth()`로 중점↔이음선 환산을 한곳에 모았다.

검증(계획고 스윕 24케이스, 새 계획노선): 물매 최소 1:1.202 — **1:1.2 미만 0건**
(종전 0.4~0.9까지 나왔다). 페이지 실데이터로도 13+15.7 1:1.206 확인.
tsc 통과, pytest 148 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 18:24:58 +09:00
co-authored by Claude Opus 5
parent 6a5cd725c2
commit 6391bb4c7e
2 changed files with 62 additions and 4 deletions
@@ -49,6 +49,7 @@ import type { FillSlopeSegment } from "./B06_Section_UI_Cross_Culvert_Solve";
import {
clampWallOffset,
cutLength,
enforceMinFillRatio,
outletSlopeFactory,
fillSlopeOf,
groundInterpolator,
@@ -125,9 +126,17 @@ export function computeCulvertLayout(
// "5m 이상이면 기슭막이 의무"라는 뜻이라 여기서 자르지 않는다.
let inletAutoOffset: number | null = null;
if (!basinReason && designAt) {
inletAutoOffset = slopeToeOffset(designAt, groundAt, inletInfo.edge.offset_m, inletInfo.limit);
const inletBaseAt = (offset: number): number =>
Math.min(groundAt(offset), invertCap(inletInfo.edge));
// 사면 끝이 기본 자리지만, 그 자리 물매가 1:1.2보다 급하면 규정 위반이라 바깥으로 민다.
inletAutoOffset = enforceMinFillRatio(
inletInfo.edge,
inletInfo.outward,
wallHeightFor(culvert.inlet),
inletBaseAt,
slopeToeOffset(designAt, groundAt, inletInfo.edge.offset_m, inletInfo.limit),
inletInfo.limit,
);
const shifted = clampWallOffset(
inletAutoOffset,
inletAutoOffset + inletInfo.outward * (revetShift?.inlet ?? 0),
@@ -404,7 +413,16 @@ export function computeCulvertLayout(
const invertAt = (offset: number): number => Math.min(groundAt(offset), inlet.elevation);
// 유출 벽 기본 자리도 **사면 끝**(= outletAnchorOffset)이고 그 자리가 하단선 중점이다.
let outletWallAnchor = outletAnchor;
const outletAutoOffset = outletAnchorOffset;
const outletAutoOffset = designAt
? enforceMinFillRatio(
outletInfo.edge,
outletInfo.outward,
wallHeightFor(culvert.outlet),
(offset) => Math.min(groundAt(offset), inlet.elevation),
outletAnchorOffset,
outletInfo.limit,
)
: outletAnchorOffset;
if (designAt) {
const shifted = clampWallOffset(
outletAutoOffset,
@@ -10,10 +10,17 @@ import {
FILL_SLOPE_MAX_LENGTH_M,
FILL_SLOPE_RATIO_MAX,
FILL_SLOPE_RATIO_MIN,
REVET_LEAN_RATIO,
REVET_THICKNESS_M,
REVET_TRAP_TOP_M,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type { OffsetPoint, PipeEnd } from "./B06_Section_UI_Cross_Culvert_Types";
/** 기슭막이 하단선 폭(m) — 사다리꼴 밑변. 자리 기준(하단선 중점) 환산에 쓴다. */
export function fillWallBaseWidth(height: number): number {
return REVET_THICKNESS_M * 1.5 + REVET_LEAN_RATIO * height;
}
/** 유효 지반 샘플 → offset 오름차순 보간기. 범위 밖은 끝값 클램프, 샘플 없으면 null. */
export function groundInterpolator(samples: SectionSample[]): ((offset: number) => number) | null {
const ground = samples
@@ -175,8 +182,8 @@ export function clampWallOffset(
const rise = edge.elevation_m - (baseAt(candidate) + height);
if (rise < FILL_MIN_RISE_M) break;
if (inward) {
const run = Math.abs(candidate + outward * REVET_TRAP_TOP_M - edge.offset_m);
if (run / rise < FILL_SLOPE_RATIO_MIN) break;
const joint = candidate - outward * (fillWallBaseWidth(height) / 2 - REVET_TRAP_TOP_M);
if (Math.abs(joint - edge.offset_m) / rise < FILL_SLOPE_RATIO_MIN) break;
}
allowed = candidate;
}
@@ -219,3 +226,36 @@ export function outletSlopeFactory(
};
};
}
/**
* 성토 물매가 **1:1.2보다 급하면** 규정 위반이다(`성토_비탈면.md` §1 — 1:1.2~2.0).
* 사면 끝에 세운 자리가 그렇다면 물매가 1:1.2에 닿을 때까지 **바깥으로** 민다.
* 지반이 1:1.2보다 급해 끝내 못 맞추면 시작 자리를 그대로 돌려준다 — 그때는
* 기슭막이로 못 받는 자리라 옹벽·석축 검토 대상이고 경고로 알린다.
*/
export function enforceMinFillRatio(
edge: { offset_m: number; elevation_m: number },
outward: number,
height: number,
baseAt: (offset: number) => number,
startOffset: number,
limitOffset: number,
): number {
// 자리 기준은 **하단선 중점**이라 이음선 상단점은 중점보다 안쪽에 있다.
const jointOf = (offset: number): number =>
offset - outward * (fillWallBaseWidth(height) / 2 - REVET_TRAP_TOP_M);
const ratioAt = (offset: number): number => {
const rise = edge.elevation_m - (baseAt(offset) + height);
if (!(rise > 1e-6)) return Number.POSITIVE_INFINITY;
return Math.abs(jointOf(offset) - edge.offset_m) / rise;
};
if (ratioAt(startOffset) >= FILL_SLOPE_RATIO_MIN) return startOffset;
const span = (limitOffset - startOffset) * outward;
if (!(span > 0)) return startOffset;
const steps = 200;
for (let i = 1; i <= steps; i += 1) {
const candidate = startOffset + outward * ((span * i) / steps);
if (ratioAt(candidate) >= FILL_SLOPE_RATIO_MIN) return candidate;
}
return startOffset;
}