- 기슭막이 높이 = 관경 + 여유고 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>
222 lines
9.9 KiB
TypeScript
222 lines
9.9 KiB
TypeScript
/* =============================================================================
|
||
* 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,
|
||
};
|
||
}
|