/* ============================================================================= * 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, PIPE_CONNECT_GRADE, FILL_SLOPE_RATIO_MIN, REVET_HEIGHT_STEP_M, REVET_LEAN_RATIO, REVET_THICKNESS_M, REVET_TRAP_TOP_M, } from "./B06_Section_UI_Cross_Culvert_Const"; import type { OffsetPoint, PipeEnd, WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; import { slopedCrossing } from "./B06_Section_UI_Cross_Culvert_Extra"; /** 기슭막이 하단선 폭(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" | "extra"; 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)), }; }; } /** * 손으로 민 벽 자리 한계 — 안쪽 당김은 **1:1.2가 성립하는 가장 안쪽 자리**(innerLimit * = 자동 자리)에서 멈춘다(2026-08-22 사용자 확정 — 우선순위: 배관 연장 > 높이 증가 > * 바닥 띄움. 당겨서 물매가 깨질 바에는 관을 길게 유지한다). 1:1.2 자리가 아예 없는 * 지형 폴백에서는 innerLimit이 노견까지 내려오고, 그때만 높이 증가·바닥 띄움 * (`solveWallVertical`)이 발동한다. 바깥 밀기는 막지 않는다(노폭 연장으로 흡수). */ export function clampWallOffset( wantedOffset: number, innerLimitOffset: number, outward: number, ): number { return (wantedOffset - innerLimitOffset) * outward < 0 ? innerLimitOffset : wantedOffset; } /** * 기슭막이 **자동 자리 = 노견(노폭 연장)이 최소가 되는 지점**(2026-08-22 사용자 확정). * 노견 끝에서 바깥으로 훑어, 표준 높이 벽으로 ① 성토고 하한(FILL_MIN_RISE_M)과 * ② 물매 1:1.2 이상(급하지 않음)을 처음 만족하는 자리를 돌려준다 — 그 자리가 * 물매 1:1.2가 딱 성립하는 가장 안쪽 자리라 노폭 연장이 0에 수렴한다. * (관 m단위 올림 잔여가 벽을 바깥으로 밀면 그만큼만 노견이 조정된다.) * * 사면 끝(toeOffset) **밖**의 자리는 사면 끝에 성토고가 서 있는데 물매만 급한 경우 * (규정 위반 교정)만 인정한다 — 성토가 얕아 자리 자체가 안 서는 지형에서 절벽 너머로 * 폭주하는 것을 막는다(계획고 인하 스윕에서 노폭 연장 11m 관측). 못 찾으면 null — * 호출부가 사면 끝으로 폴백한다. */ export function minShoulderWallOffset( edge: { offset_m: number; elevation_m: number }, outward: number, height: number, baseAt: (offset: number) => number, toeOffset: number, limitOffset: number, ): number | null { const span = (limitOffset - edge.offset_m) * outward; if (!(span > 0)) return null; const steps = 240; for (let i = 1; i <= steps; i += 1) { const offset = edge.offset_m + outward * ((span * i) / steps); const rise = edge.elevation_m - (baseAt(offset) + height); if (rise < FILL_MIN_RISE_M) continue; const joint = offset - outward * (fillWallBaseWidth(height) / 2 - REVET_TRAP_TOP_M); if (((joint - edge.offset_m) * outward) / rise < FILL_SLOPE_RATIO_MIN) continue; if ((offset - toeOffset) * outward > 1e-6) { const toeRise = edge.elevation_m - (baseAt(toeOffset) + height); if (toeRise < FILL_MIN_RISE_M) return null; // 성토 얕음 — 사면 끝 폴백 } return offset; } return null; } /** 벽의 수직 제원 — 높이·바닥 표고·바닥이 원지반에서 뜬 간격. */ export interface WallVertical { height: number; baseElevation: number; /** 바닥~원지반 간격(m). 0이면 지반 위에 선다. 뜬 공간은 별도 구조물 예정. */ floatGapM: number; } /** * 벽 자리에 맞는 **수직 제원**을 푼다(2026-08-22 사용자 확정 — 최소 노견 유지 규칙). * 벽 상단(이음선 상단점)은 기본 노견에서 내려오는 **1:1.2 사면선 위**에 놓여야 한다. * ① 자리가 안쪽이라 표준 높이(관경+여유고)로 사면선에 못 닿으면 → 높이를 키운다. * ② 형태별 높이 한계(찰 3.0/메 2.0)에 걸리면 → 바닥을 원지반에서 띄운다 * (뜬 공간은 별도 지지 구조물 — 추가 예정). * ③ 자리가 바깥이라 사면선이 표준 높이 아래로 지나면 → 표준 높이 유지, 모자란 * 물매는 `fillSlopeOf`가 노폭 연장으로 맞춘다(기존 규칙). * 높이는 0.1m 눈금 올림(2026-08-21 확정)이라 상단이 사면선보다 반 눈금쯤 높을 수 있다. */ export function solveWallVertical( anchorOffset: number, edge: { offset_m: number; elevation_m: number }, outward: number, groundBase: number, minHeight: number, limitHeight: number, ): WallVertical { const gridUp = (value: number): number => Math.ceil(value / REVET_HEIGHT_STEP_M - 1e-9) * REVET_HEIGHT_STEP_M; let height = minHeight; let desiredTop = groundBase + minHeight; for (let i = 0; i < 4; i += 1) { const joint = anchorOffset - outward * (fillWallBaseWidth(height) / 2 - REVET_TRAP_TOP_M); const run = Math.max(0, (joint - edge.offset_m) * outward); desiredTop = edge.elevation_m - run / FILL_SLOPE_RATIO_MIN; const next = Math.min(Math.max(gridUp(desiredTop - groundBase), minHeight), limitHeight); if (Math.abs(next - height) < 1e-9) { height = next; break; } height = next; } const floatGap = Math.max(0, desiredTop - limitHeight - groundBase); return { height, baseElevation: groundBase + floatGap, floatGapM: floatGap }; } /** 교점을 마감면 **선분 안**으로 자른다 — 선분 밖(벽 하단 아래 등)으로 새지 않게. */ export function clampToFace( point: OffsetPoint, face: { base: OffsetPoint; direction: { offset: number; elevation: number } }, ): OffsetPoint { const dot = (point.offset - face.base.offset) * face.direction.offset + (point.elevation - face.base.elevation) * face.direction.elevation; const len2 = face.direction.offset * face.direction.offset + face.direction.elevation * face.direction.elevation; const t = len2 > 1e-12 ? Math.min(Math.max(dot / len2, 0), 1) : 0; return { offset: face.base.offset + face.direction.offset * t, elevation: face.base.elevation + face.direction.elevation * t, }; } /** 설계선을 따라 [from → to] 구간의 경사길이(m)를 잰다 — 성토사면 길이 측정용. */ export function slopeLengthAlong( designAt: (offset: number) => number, from: number, to: number, ): number { const steps = 60; let length = 0; let prevElev = designAt(from); for (let i = 1; i <= steps; i += 1) { const o = from + ((to - from) * i) / steps; const e = designAt(o); length += Math.hypot((to - from) / steps, e - prevElev); prevElev = e; } return length; } /** 배관 기슭막이 4축 배치 결과 — 좌우 x·상하 d(적용값)와 확정 자리. */ export interface PipeWallPlacement { x: number; d: number; topElevation: number; anchorOffset: number; invert: number; } /** * 배관 기슭막이 4축 배치(2026-08-22 확정 규칙 묶음) — 유입·유출 공용. * · x = 노견 연장 평행이동(안쪽 금지), d = 성토선 대각(위 한계 = 최소 성토고· * invert 상한(토피/역경사)). * · requireCrossing(유출): 매몰인데 0도 절토선이 원지반과 다시 못 만나는 자리는 * 금지 — 자동 자리 쪽으로 0.05m씩 되돌린다(상하 먼저, 다음 좌우). */ export function placePipeWall(input: { autoOffset: number; outward: number; height: number; /** 자동 자리의 invert(= baseAt(autoOffset)). */ baseElevation0: number; edgeElevation: number; /** invert 상한 — 유입: 노면−관경−토피, 유출: 유입 invert(역경사 방지). */ invertCap: number; adjust: WallAdjust; groundAt: (offset: number) => number; limitOffset: number; requireCrossing: boolean; }): PipeWallPlacement { const top0 = input.baseElevation0 + input.height; const dLow = Math.max( -FILL_SLOPE_RATIO_MIN * (input.edgeElevation - FILL_MIN_RISE_M - top0), -FILL_SLOPE_RATIO_MIN * (input.invertCap - (top0 - input.height)), ); let d = Math.max(input.adjust.d, Math.ceil(dLow * 10) / 10); let x = Math.max(input.adjust.x, 0); if (input.requireCrossing) { const placeable = (anchorOffset: number, invertE: number): boolean => invertE >= input.groundAt(anchorOffset) - 0.01 || slopedCrossing( { offset: anchorOffset, elevation: invertE }, input.outward, -PIPE_CONNECT_GRADE, input.groundAt, input.limitOffset, ) !== null; for (let pass = 0; pass < 400; pass += 1) { const anchorOffset = input.autoOffset + input.outward * (x + d); if (placeable(anchorOffset, top0 - d / FILL_SLOPE_RATIO_MIN - input.height)) break; if (Math.abs(d) > 1e-9) d = d > 0 ? Math.max(0, d - 0.05) : Math.min(0, d + 0.05); else if (x > 1e-9) x = Math.max(0, x - 0.05); else break; } d = Math.round(d * 10) / 10; x = Math.round(x * 10) / 10; } const topElevation = top0 - d / FILL_SLOPE_RATIO_MIN; return { x, d, topElevation, anchorOffset: input.autoOffset + input.outward * (x + d), invert: topElevation - input.height, }; }