Files
Aislo/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts
T
eomsangdonandClaude Opus 5 8564e118d6 fix(B06): 조정 단위를 관 길이 1m로·벽 형상 불변·사면 역전 차단
지적 3건:
- 우측(유출) 기슭막이만 좌우로 밀 때 단면이 변했다. 0.1m씩 미니 관 길이 정수의
  끝수를 벽 폭이 흡수해 0.45→0.90까지 뚱뚱해졌다. 조정 단위를 **관 길이 1m**로
  바꾸고, 조정창으로 옮긴 벽은 폭을 절대 건드리지 않게 했다. 자리만 옮기면 정수
  길이가 성립하므로 형상이 그대로다(검증: 두께 0.450 고정, L 1m씩 증감).
- 성토 사면이 역전되는 자리가 있었다. 벽 상단이 노견보다 높아지면 사면선이 위로
  올라간다 — 최소 성토고 0.3m(FILL_MIN_RISE_M)를 두고 그 앞에서 멈춘다.
- 이동이 노견을 넘어 도로 반대편까지 갔다. 벽은 제 노견 바깥에만 서게 막았다.

조정창은 "관 길이 8m · 바깥 2.0m"처럼 관 길이를 같이 적는다.
700줄 유지를 위해 clampWallOffset·cutLength·outletSlopeFactory를 _Solve로 옮겼다.

검증: 이동 ±5m 20케이스 역전 0건·두께 고정, 275.71 유출 이동 11↔7m 정상,
계획고 스윕 24케이스 회귀 문제 0. tsc 통과, pytest 148 pass.

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

285 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,
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";
/** 유효 지반 샘플 → 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,
};
}
/**
* 손으로 민 벽 자리를 **사면이 역전되지 않는 범위**로 되돌린다(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;
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;
if (edge.elevation_m - (baseAt(candidate) + height) < FILL_MIN_RISE_M) 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)),
};
};
}