Files
Aislo/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts
T
eomsangdonandClaude Opus 5 a04da05fe1 feat(B06): 배수관 기슭막이 여유고·성토 물매 재설계
- 기슭막이 높이 = 관경 + 여유고 0.5m (0.1m 눈금 올림). 좌우 여유고 동일.
- 관 길이는 절단 긴 변 기준 정수 m. 벽 자리 이동으로 맞추고, 물매·5m 제약에
  걸리면 벽 폭으로 흡수(폭은 5m 한계를 넘지 않는 범위까지만).
- 성토 물매를 1:1.2 고정에서 1:1.2~2.0 범위(성토_비탈면.md §1)로 전환.
  벽 자리가 굳은 뒤 물매를 역산해 사면선이 벽 이음선 상단점을 지나게 한다.
  종전에는 관 길이 맞춤이 벽을 옮겨 접점이 깨졌고 스냅으로 가리고 있었다.
- 성토 사면선은 단일 각도 직선 하나를 배수관 레이아웃이 직접 공급(설계선 대체).
- 벽 자리 탐색 상한 = 성토 사면 끝, 사면길이 5m 초과 전까지. 성토고 3m 이상이면
  5m 쪽으로 끌어올린다.
- 기슭막이로 성토를 못 받는 자리는 structureRequired로 표시(옹벽·석축 검토).
- 700줄 제한으로 Const/Types/Solve 3개 파일 분리.

검증: 계획고 -2.0~+3.0 스윕 24케이스 문제 0 (접점 오차 <1e-6, 여유고 0.5,
5m 초과는 전부 경고). tsc 통과, pytest 148 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 15:51:54 +09:00

222 lines
9.9 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.
/* =============================================================================
* B06_Section_UI_Cross_Culvert_Solve.ts
* 배수관 세트 기하의 **보조 풀이** — 보간기·직선 교점·사면 끝 탐색·성토측 구조물
* 자리 풀이. 본체(`_Cross_Culvert_Geom.ts`)에서 분리했다(700줄 제한).
* ========================================================================== */
import type { SectionSample } from "./B06_Section_Api_Fetch";
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 } from "./B06_Section_UI_Cross_Culvert_Types";
/** 유효 지반 샘플 → offset 오름차순 보간기. 범위 밖은 끝값 클램프, 샘플 없으면 null. */
export function groundInterpolator(samples: SectionSample[]): ((offset: number) => number) | null {
const ground = samples
.filter((s) => s.valid !== false && s.elevation_m !== null && Number.isFinite(s.elevation_m))
.map((s) => ({ offset: s.offset_m ?? 0, elevation: s.elevation_m as number }))
.sort((a, b) => a.offset - b.offset);
if (!ground.length) return null;
return (offset: number): number => {
if (offset <= ground[0].offset) return ground[0].elevation;
const last = ground[ground.length - 1];
if (offset >= last.offset) return last.elevation;
for (let i = 1; i < ground.length; i += 1) {
if (offset > ground[i].offset) continue;
const a = ground[i - 1];
const b = ground[i];
const span = b.offset - a.offset;
if (span <= 0) return b.elevation;
return a.elevation + (b.elevation - a.elevation) * ((offset - a.offset) / span);
}
return last.elevation;
};
}
/** 설계선 보간기(offset 오름차순, 범위 밖 클램프). 설계선이 없으면 null. */
export function designInterpolator(
line: Array<{ offset_m: number; elevation_m: number }> | undefined,
): ((offset: number) => number) | null {
if (!line || line.length < 2) return null;
const sorted = [...line].sort((a, b) => a.offset_m - b.offset_m);
return (offset: number): number => {
if (offset <= sorted[0].offset_m) return sorted[0].elevation_m;
const last = sorted[sorted.length - 1];
if (offset >= last.offset_m) return last.elevation_m;
for (let i = 1; i < sorted.length; i += 1) {
if (offset > sorted[i].offset_m) continue;
const a = sorted[i - 1];
const b = sorted[i];
const span = b.offset_m - a.offset_m;
if (span <= 0) return b.elevation_m;
return a.elevation_m + (b.elevation_m - a.elevation_m) * ((offset - a.offset_m) / span);
}
return last.elevation_m;
};
}
/** 두 직선(점 + 방향)의 교점. 거의 나란하면 null. */
export function intersect(
p: OffsetPoint,
d: { offset: number; elevation: number },
q: OffsetPoint,
u: { offset: number; elevation: number },
): OffsetPoint | null {
const cross = u.offset * d.elevation - u.elevation * d.offset;
if (Math.abs(cross) < 1e-9) return null;
const t = ((p.offset - q.offset) * d.elevation - (p.elevation - q.elevation) * d.offset) / cross;
return { offset: q.offset + u.offset * t, elevation: q.elevation + u.elevation * t };
}
/** 성토사면 길이 한계(m) — **법정**: 별표2 .2.차.(3) "성토사면 길이 5m 이내, 초과 시
* 옹벽·석축 등 구조물 설치 의무"(지식DB 성토_비탈면.md §2). 유출 벽 자리의 바깥 한계. */
/** 관 축과 벽 전면이 거의 나란하면 교점이 멀리 발산한다 — 이 거리를 넘으면 축 직각 마감. */
export const STRAY_LIMIT_M = 2.5;
/** 노견에서 바깥으로 훑어 설계선(사면)이 지반과 만나는 첫 지점(사면 끝)을 찾는다.
* 사면 경사길이 5m 한계(별표2)에 걸리면 그 지점에서 멈춘다 — 거기가 구조물 자리다. */
export function slopeToeOffset(
designAt: (offset: number) => number,
groundAt: (offset: number) => number,
startOffset: number,
limitOffset: number,
): number {
const stepCount = 120;
const step = (limitOffset - startOffset) / stepCount;
if (!Number.isFinite(step) || step === 0) return limitOffset;
let previousDiff = designAt(startOffset) - groundAt(startOffset);
let previousElevation = designAt(startOffset);
let slopeLength = 0;
for (let i = 1; i <= stepCount; i += 1) {
const offset = startOffset + step * i;
const elevation = designAt(offset);
slopeLength += Math.hypot(step, elevation - previousElevation);
previousElevation = elevation;
const diff = elevation - groundAt(offset);
if (previousDiff !== 0 && Math.sign(diff) !== Math.sign(previousDiff)) return offset;
if (Math.abs(diff) < 0.02) return offset;
if (slopeLength >= FILL_SLOPE_MAX_LENGTH_M) return offset;
previousDiff = diff;
}
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;
lengthM: number;
clamped: boolean;
fillHeightM: number;
from: OffsetPoint;
to: OffsetPoint;
}
/**
* 벽이 선 자리에서 성립하는 성토 사면 구간을 만든다. 벽 자리가 다 굳은 뒤에 불러야
* 한다 — 관 길이 맞춤(벽 이동)이 끝나기 전에 물매를 잡으면 접점이 다시 어긋난다
* (2026-08-21 사용자 ② 어긋남의 직접 원인이 이 순서였다).
*/
export function fillSlopeOf(
wall: { role: "inlet" | "outlet"; topJoint: OffsetPoint },
edge: { offset_m: number; elevation_m: number },
): FillSlopeSegment {
const from: OffsetPoint = { offset: edge.offset_m, elevation: edge.elevation_m };
const to = wall.topJoint;
const run = Math.abs(to.offset - from.offset);
const rise = from.elevation - to.elevation;
// 그림과 어긋나지 않도록 **실제 물매**를 그대로 보고하고, 1:1.2~2.0을 벗어났는지만
// 따로 알린다(끝값으로 바꿔 보고하면 도면과 수치가 갈린다).
const ratio = rise > 1e-6 ? run / rise : Number.POSITIVE_INFINITY;
// 성토고가 0.5m도 안 되면 사면이랄 게 없다 — 물매가 발산해도 경고하지 않는다.
const meaningful = rise >= 0.5;
return {
ratio,
lengthM: Math.hypot(run, rise),
clamped: meaningful && (ratio < FILL_SLOPE_RATIO_MIN || ratio > FILL_SLOPE_RATIO_MAX),
fillHeightM: rise,
from,
to,
};
}