Files
Aislo/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts
T
eomsangdonandClaude Opus 5 67ec86b05f feat(B06): 성토 물매 1:1.2 고정 — 벽이 나간 만큼 노폭 연장
물매를 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>
2026-08-21 18:35:53 +09:00

275 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_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;
}