물매를 1:1.2~2.0 범위에서 역산하던 방식을 걷어내고 **수평 1.2 : 수직 1 고정**으로 바꿨다(2026-08-21 사용자 확정). 벽이 1:1.2 사면이 닿는 자리보다 밖에 있으면 물매를 완만하게 바꾸는 대신 **노폭을 그만큼 늘린다** — 기슭막이를 바깥으로 1m 밀면 노폭이 약 1m 늘어난다. - fillSlopeOf가 [원 노견 → 연장된 노견 → 벽 이음선 상단점] 폴리라인을 돌려주고, 설계선 오버레이가 그 폴리라인을 그린다(노면 연장 구간이 수평으로 붙는다). - 반대로 벽이 안쪽이라 물매가 1:1.2보다 급해지는 자리는 노폭으로 못 맞춘다(도로를 좁힐 수는 없다) — 자동 자리를 1:1.2가 될 때까지 바깥으로 미는 처리를 유지한다. - fillSlope.roadWideningM 신설, 툴팁에 "노폭 연장 x.xxm" 표시. 검증(계획고 스윕 24케이스): 물매 23건이 정확히 1:1.200(나머지 1건은 성토고 0.2m로 사면이랄 게 없는 측점). 벽 이동 시험 — 밖으로 1m씩 밀 때 노폭 연장 1.52→3.27→ 4.22→5.21m, 물매는 계속 1:1.200. tsc 통과, pytest 148 pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
275 lines
12 KiB
TypeScript
275 lines
12 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_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;
|
||
/** 노폭 연장량(m, 편측). 벽이 1:1.2 사면이 닿는 자리보다 밖에 있으면 그만큼 늘린다. */
|
||
wideningM: number;
|
||
/** 그릴 폴리라인 — [원 노견, (연장된 노견), 벽 이음선 상단점]. */
|
||
points: OffsetPoint[];
|
||
}
|
||
|
||
/**
|
||
* 벽이 선 자리에서 성토 사면 구간을 만든다. 물매는 **1:1.2 고정**(수평 1.2 : 수직 1,
|
||
* 2026-08-21 사용자 확정)이고, 벽이 그 사면이 닿는 자리보다 밖에 있으면 부족한 만큼
|
||
* **노폭을 늘린다** — 물매를 완만하게 바꾸지 않는다.
|
||
*
|
||
* 벽 자리가 다 굳은 뒤에 불러야 한다 — 관 길이 맞춤(벽 이동)이 끝나기 전에 잡으면
|
||
* 접점이 다시 어긋난다.
|
||
*/
|
||
export function fillSlopeOf(
|
||
wall: { role: "inlet" | "outlet"; topJoint: OffsetPoint; outward: number },
|
||
edge: { offset_m: number; elevation_m: number },
|
||
): FillSlopeSegment {
|
||
const to = wall.topJoint;
|
||
const rise = edge.elevation_m - to.elevation;
|
||
const edgePoint: OffsetPoint = { offset: edge.offset_m, elevation: edge.elevation_m };
|
||
if (!(rise > 1e-6)) {
|
||
return {
|
||
ratio: FILL_SLOPE_RATIO_MIN,
|
||
lengthM: 0,
|
||
clamped: false,
|
||
fillHeightM: Math.max(rise, 0),
|
||
wideningM: 0,
|
||
points: [edgePoint, to],
|
||
};
|
||
}
|
||
// 1:1.2 사면이 벽 상단에 닿으려면 노견이 여기까지 나와 있어야 한다.
|
||
const neededEdgeOffset = to.offset - wall.outward * FILL_SLOPE_RATIO_MIN * rise;
|
||
const widening = Math.max(0, (neededEdgeOffset - edge.offset_m) * wall.outward);
|
||
const startOffset = edge.offset_m + wall.outward * widening;
|
||
const points: OffsetPoint[] = [edgePoint];
|
||
if (widening > 1e-6) points.push({ offset: startOffset, elevation: edge.elevation_m });
|
||
points.push(to);
|
||
const run = Math.abs(to.offset - startOffset);
|
||
return {
|
||
ratio: run / rise,
|
||
lengthM: Math.hypot(run, rise),
|
||
// 벽이 안쪽으로 들어와 1:1.2보다 급해진 경우만 경고한다(연장은 정상 동작).
|
||
clamped: run / rise < FILL_SLOPE_RATIO_MIN - 1e-6,
|
||
fillHeightM: rise,
|
||
wideningM: widening,
|
||
points,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 관 절단 길이(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)),
|
||
};
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 손으로 민 벽 자리를 **성토가 성립하는 범위**로 되돌린다.
|
||
* · 벽은 제 노견 바깥에 선다(도로 밑을 파고들면 안 된다)
|
||
* · 성토고 하한(FILL_MIN_RISE_M) — 그 아래면 사면선이 역전된다
|
||
* · 안쪽으로 당길 때는 **물매 1:1.2**를 넘지 않는 자리까지만(2026-08-21 사용자 지시).
|
||
* 바깥으로 밀 때는 물매가 1:1.2로 고정된 채 노폭이 늘어나므로 막지 않는다.
|
||
*/
|
||
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;
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 사면 끝 자리의 물매가 **1:1.2보다 급하면** 규정 위반이라 1:1.2가 될 때까지 바깥으로
|
||
* 민다. 반대로 완만한 쪽(1:1.2보다 느슨)은 `fillSlopeOf`가 **노폭을 늘려** 맞추므로
|
||
* 여기서 건드리지 않는다(2026-08-21 사용자 확정 — 물매는 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 ratioAt = (offset: number): number => {
|
||
const rise = edge.elevation_m - (baseAt(offset) + height);
|
||
if (!(rise > 1e-6)) return Number.POSITIVE_INFINITY;
|
||
const joint = offset - outward * (fillWallBaseWidth(height) / 2 - REVET_TRAP_TOP_M);
|
||
return Math.abs(joint - 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;
|
||
}
|