- 조정창 숫자가 실제와 갈렸다. 안쪽으로 계속 누르니 "안쪽 10.0m"까지 올라가는데 벽은 노견 한계에서 멈춰 있었다. 기하가 **실제 적용한 이동량**(revetShift)을 돌려 주고, 카드가 그 값을 제어기에 되담는다 — 이제 한계에 닿으면 숫자도 멈춘다. - 안쪽으로 당기면 사면이 짧아져 물매가 급해진다. **1:1.2(가장 급한 한계)**를 넘지 않는 자리까지만 들어가게 막았다(2026-08-21 사용자 지시). 바깥으로 밀 때는 완만해 지므로 막지 않고 범위 밖이면 경고로만 알린다. 검증: 4+4.3은 자동 자리가 이미 1:1.21이라 안쪽 이동 0(차단), 13+15.7은 −1m까지 허용(1:1.61)하고 그 이상 차단. 조정창 숫자도 같은 자리에서 멈춘다. 두께 16케이스 0.450 고정, 계획고 스윕 24케이스 문제 0. tsc 통과, pytest 148 pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
294 lines
13 KiB
TypeScript
294 lines
13 KiB
TypeScript
/* =============================================================================
|
||
* 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;
|
||
// 안쪽으로 당기면 사면이 짧아져 물매가 급해진다 — **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 run = Math.abs(candidate + outward * REVET_TRAP_TOP_M - edge.offset_m);
|
||
if (run / 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)),
|
||
};
|
||
};
|
||
}
|