/* ============================================================================= * B06_Section_UI_Cross_Culvert_Solve.ts * 배수관 세트 기하의 **보조 풀이** — 보간기·직선 교점·사면 끝 탐색·성토측 구조물 * 자리 풀이. 본체(`_Cross_Culvert_Geom.ts`)에서 분리했다(700줄 제한). * ========================================================================== */ import type { SectionSample } from "./B06_Section_Api_Fetch"; import { 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 } 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, }; }