feat(B06): 기슭막이 자리·하단선 규칙 재정립

2026-08-21 사용자 확정:
- 기본 자리 = **성토 사면이 원지반과 만나는 지점**(사면 끝). 사면이 5m를 넘으면
  기슭막이가 사면을 5m에서 끊는다 — 5m는 자리 한계가 아니라 "5m 이상이면 기슭막이
  의무"라는 규정(성토_비탈면.md §2)의 표현이다. 물매 1:1.2~2.0으로 자리를 풀던
  방식은 걷어냈다(자유도 축소였다).
- 벽 하단선 = **원지반을 수직 0.5m 내린 선**. 수평 바닥이라 사면에서 앞 모서리가
  뜨던 문제가 사라진다(배면·전면 모두 근입 0.500 확인).
- 자리 기준(이동 중심) = **하단선 중점**. 배면 기준이면 벽이 기울 때 체감 위치가
  어긋난다.
- 관 유출단 invert = 그 자리 원지반 → 관 하단 경사가 지반과 만난다.
- structureRequired = 사면길이 5m 이상(의무 발생), 종전의 "받을 수 없는 자리" 판정 대체.

그리기도 기운 하단선에 맞췄다 — 이음선 바닥·돌 해칭이 하단선 위에서 잡힌다.

검증: 두 측점 이동 10케이스 근입 배면·전면 모두 0.500, 하단선 중점이 원지반과 일치
(관 수평 클램프 2건 제외), 계획고 스윕 24케이스 문제 0. tsc 통과, pytest 148 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 17:56:35 +09:00
co-authored by Claude Opus 5
parent 564b289ee6
commit 38acdd02ed
4 changed files with 81 additions and 169 deletions
@@ -10,7 +10,6 @@ import {
FILL_SLOPE_MAX_LENGTH_M,
FILL_SLOPE_RATIO_MAX,
FILL_SLOPE_RATIO_MIN,
FILL_STRUCTURE_HEIGHT_M,
REVET_TRAP_TOP_M,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type { OffsetPoint, PipeEnd } from "./B06_Section_UI_Cross_Culvert_Types";
@@ -80,7 +79,8 @@ export function intersect(
export const STRAY_LIMIT_M = 2.5;
/** 노견에서 바깥으로 훑어 설계선(사면)이 지반과 만나는 첫 지점(사면 끝)을 찾는다.
* 사면 경사길이 5m 한계(별표2)에 걸리면 그 지점에서 멈춘다 — 거기가 구조물 자리다. */
* 5m는 자리 한계가 아니다 — 사면이 5m 이상이면 **기슭막이가 의무**라는 뜻일 뿐이라
* 여기서 끊지 않는다(2026-08-21 사용자 정정). */
export function slopeToeOffset(
designAt: (offset: number) => number,
groundAt: (offset: number) => number,
@@ -93,6 +93,10 @@ export function slopeToeOffset(
let previousDiff = designAt(startOffset) - groundAt(startOffset);
let previousElevation = designAt(startOffset);
let slopeLength = 0;
// 자리 = **성토선이 지반과 만나는 점**과 **사면길이 5m 지점** 중 안쪽. 사면이 5m를
// 넘으면 기슭막이가 있어야 한다는 규정(성토_비탈면.md §2)은 곧 기슭막이가 사면을
// 5m에서 끊어 준다는 뜻이다. 교차점이 5m 안이면 거기가 자연스러운 사면 끝이다.
// 더 밖으로 두고 싶으면 조정창으로 민다(2026-08-21 사용자 확정).
for (let i = 1; i <= stepCount; i += 1) {
const offset = startOffset + step * i;
const elevation = designAt(offset);
@@ -107,82 +111,6 @@ export function slopeToeOffset(
return limitOffset;
}
/** 성토측 구조물 자리 풀이 결과 — 벽 offset과 그 자리에서 성립하는 사면 물매. */
export interface FillWallPlacement {
offset: number;
/** 사면선이 벽 이음선 상단점을 지나는 물매(1:n). 범위 밖이면 끝값으로 고정된 값. */
ratio: number;
slopeLengthM: number;
withinLimit: boolean;
ratioClamped: boolean;
/** 그 자리의 성토 전체고(노견 − 벽 상단). 3m 이상이면 벽을 5m 쪽으로 끌어올린다. */
fillHeightM: number;
}
/**
* 성토측 기슭막이 자리 풀이 — 노견에서 바깥으로 훑으며 **성토 사면선이 벽 이음선
* 상단점(평행사변형↔사다리꼴 상단 교차점)을 지나는** 자리를 찾는다.
*
* 종전에는 사면선(백엔드 1:1.2 고정)과의 교차로 벽 **높이**를 정했으나, 높이가
* 관경+여유고로 고정된 뒤로는 그 방식이 성립하지 않는다. 물매는 고정값이 아니라
* **1:1.2~2.0 범위**(`성토_비탈면.md` §1)이므로, 벽 자리를 정하면 물매가 따라 정해진다.
* 노견에서 멀어질수록 물매는 급 → 완만으로 단조 증가하므로 **범위에 처음 드는 자리**
* (= 사면이 가장 짧은 1:1.2 쪽)를 고른다. 사면길이 5m(별표2)를 넘기 전까지만 훑는다.
*
* 범위 안에 드는 자리가 없으면(급경사 지형) 5m 한계 직전 자리를 쓰고 물매를 끝값으로
* 고정한다 — `ratioClamped`로 알린다.
*/
export function solveFillWallOffset(
edge: { offset_m: number; elevation_m: number },
outward: number,
height: number,
baseAt: (offset: number) => number,
limitOffset: number,
): FillWallPlacement | null {
const span = (limitOffset - edge.offset_m) * outward;
if (!(span > 0)) return null;
const steps = 400;
const inRange: FillWallPlacement[] = [];
let last: FillWallPlacement | null = null;
for (let i = 0; i <= steps; i += 1) {
const offset = edge.offset_m + outward * ((span * i) / steps);
const joint = offset + outward * REVET_TRAP_TOP_M;
const rise = edge.elevation_m - (baseAt(offset) + height);
if (!(rise > 1e-6)) continue;
const run = Math.abs(joint - edge.offset_m);
const slopeLengthM = Math.hypot(run, rise);
// 사면길이 5m를 넘는 자리는 구조물 의무 구간이라 벽 자리 후보가 아니다(별표2).
if (slopeLengthM > FILL_SLOPE_MAX_LENGTH_M) break;
const candidate: FillWallPlacement = {
offset,
ratio: run / rise,
slopeLengthM,
withinLimit: true,
ratioClamped: false,
fillHeightM: rise,
};
last = candidate;
if (candidate.ratio >= FILL_SLOPE_RATIO_MIN && candidate.ratio <= FILL_SLOPE_RATIO_MAX) {
inRange.push(candidate);
}
}
if (!last) return null;
if (inRange.length) {
// 성토 전체고가 3m 이상이면 1:1.2로도 사면이 5m에 육박한다 — 벽을 **사면길이 5m
// 쪽(가장 바깥 후보)**으로 끌어올린다(2026-08-21 사용자 ①). 낮은 성토는 사면을
// 짧게 두는 **가장 안쪽 후보**를 쓴다.
return last.fillHeightM >= FILL_STRUCTURE_HEIGHT_M ? inRange[inRange.length - 1] : inRange[0];
}
// 범위 안에 드는 자리가 없다 = 지형이 1:1.2보다 급하거나 1:2.0보다 완만하다.
// 5m 한계 직전 자리를 쓰고 물매를 가까운 끝값으로 고정한다 — 도면에 경고로 알린다.
return {
...last,
ratio: last.ratio < FILL_SLOPE_RATIO_MIN ? FILL_SLOPE_RATIO_MIN : FILL_SLOPE_RATIO_MAX,
withinLimit: last.slopeLengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6,
ratioClamped: true,
};
}
/** 노견 → 벽 이음선 상단점 성토 사면 구간(단일 각도)과 그 물매·경고. */
export interface FillSlopeSegment {
ratio: number;