Files
Aislo/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts
T
eomsangdonandClaude Opus 5 6391bb4c7e 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>
2026-08-21 18:24:58 +09:00

262 lines
12 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_MIN_RISE_M,
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
.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는 자리 한계가 아니다 — 사면이 5m 이상이면 **기슭막이가 의무**라는 뜻일 뿐이라
* 여기서 끊지 않는다(2026-08-21 사용자 정정). */
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;
// 자리 = **성토선이 지반과 만나는 점**과 **사면길이 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);
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;
}
/** 노견 → 벽 이음선 상단점 성토 사면 구간(단일 각도)과 그 물매·경고. */
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,
};
}
/**
* 손으로 민 벽 자리를 **사면이 역전되지 않는 범위**로 되돌린다(2026-08-21 사용자 지적).
* 노견보다 벽 상단이 높아지면 사면선이 위로 올라가 버린다 — 자동 자리에서 목표 자리로
* 훑으며 성토고가 하한 아래로 떨어지기 직전까지만 간다.
*/
export function clampWallOffset(
autoOffset: number,
wantedOffset: number,
height: number,
edge: { offset_m: number; elevation_m: number },
baseAt: (offset: number) => number,
outward: number,
): number {
const steps = 40;
// 안쪽으로 당기면 사면이 짧아져 물매가 급해진다 — **1:1.2(가장 급한 한계)**를 넘지
// 않게 막는다(2026-08-21 사용자 지시). 바깥으로 밀 때는 완만해지므로 여기서 막지
// 않고(관 길이 조정 여지를 남긴다) 범위 밖이면 경고로만 알린다.
const inward = (wantedOffset - autoOffset) * outward < 0;
let allowed = autoOffset;
for (let i = 1; i <= steps; i += 1) {
const candidate = autoOffset + ((wantedOffset - autoOffset) * i) / steps;
// 벽은 제 노견 바깥에 선다 — 안쪽으로 넘기면 도로 밑을 파고들어 반대편까지 간다.
if ((candidate - edge.offset_m) * outward < 0) break;
const rise = edge.elevation_m - (baseAt(candidate) + height);
if (rise < FILL_MIN_RISE_M) break;
if (inward) {
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;
}
return allowed;
}
/**
* 관 절단 길이(m) — 끝이 기운 벽 전면으로 잘려 **상단·하단 길이가 다르다**.
* 표기·정수 맞춤은 **긴 변** 기준이다(2026-08-21 사용자 확정).
*/
export function cutLength(corners: { inlet: PipeEnd; outlet: PipeEnd }): number {
return Math.max(
Math.hypot(
corners.outlet.bottom.offset - corners.inlet.bottom.offset,
corners.outlet.bottom.elevation - corners.inlet.bottom.elevation,
),
Math.hypot(
corners.outlet.top.offset - corners.inlet.top.offset,
corners.outlet.top.elevation - corners.inlet.top.elevation,
),
);
}
/**
* 유출 벽을 그 자리에 세웠을 때의 성토 사면(물매 1:n·사면길이)을 재는 함수를 만든다.
* 관 길이 정수 맞춤이 벽을 옮겨도 되는 자리인지 판정하는 데 쓴다.
*/
export function outletSlopeFactory(
info: { edge: { offset_m: number; elevation_m: number }; outward: number },
invertAt: (offset: number) => number,
wallHeight: number,
): (offset: number, thickness: number) => { ratio: number; lengthM: number } {
return (offset, thickness) => {
const joint = offset + info.outward * (thickness / 2);
const rise = info.edge.elevation_m - (invertAt(offset) + wallHeight);
const run = Math.abs(joint - info.edge.offset_m);
return {
ratio: rise > 1e-6 ? run / rise : Number.POSITIVE_INFINITY,
lengthM: Math.hypot(run, Math.max(rise, 0)),
};
};
}
/**
* 성토 물매가 **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;
}